Code smells e refactoring
Diagnosticar dificuldades de mudança e transformar o código preservando os seus contratos.
A loja do jogo calcula o mesmo preço em três classes. Alterar o desconto exige editar as três, com risco de ficarem diferentes. Extrair uma regra comum resolve essa dispersão. Durante a refatoração, o preço cobrado tem de continuar igual.
O que é refactoring
Refactoring, ou refatoração, é uma transformação da estrutura interna que preserva o comportamento observável. Extrair um método ou mover uma regra pode mudar o local do código sem mudar valores, efeitos, erros ou ordem relevante.
Os testes ajudam a detetar alterações, mas não são uma prova completa de equivalência. Antes de transformar, identifica o contrato e os casos que poderiam mudar. Corrigir um erro e refatorar são passos diferentes: a correção muda um comportamento incorreto; a refatoração preserva o comportamento decidido.
Os cheiros mais comuns
Um code smell é um sinal para investigar, não uma demonstração automática de mau código. Não existe um número de linhas que decida sozinho se um método está errado. As apresentações agrupam estes sinais em cinco famílias: código inchado, abusos de orientação a objetos, entraves à mudança, código dispensável e acoplamento indevido. A tabela seguinte cruza exemplos destas famílias com a dificuldade que cada um anuncia.
| Sinal | Problema a investigar | Transformação possível |
|---|---|---|
| Long Method | Várias intenções e níveis de detalhe no mesmo método. | Extract Method, com nomes que expliquem as intenções. |
| Large Class | Razões independentes para alterar a mesma classe. | Extract Class. |
| Duplicate Code | A mesma regra exige mudanças em vários sítios. | Extrair a regra para um responsável comum. |
| Feature Envy | Uma operação depende sobretudo dos dados de outro objeto. | Move Method, se esse objeto deve ser responsável pela regra. |
| Data Clumps | Um grupo de valores viaja sempre junto. | Introduce Parameter Object ou um tipo do domínio. |
| Primitive Obsession | Primitivos representam conceitos com regras próprias. | Criar um tipo que valide e ofereça operações adequadas. |
| Long Parameter List | Muitas decisões independentes numa chamada. | Separar operações ou agrupar dados que pertencem juntos. |
| Refused Bequest | Uma subclasse recusa operações prometidas pela base. | Rever a hierarquia e usar composição. |
Feature Envy não se decide contando chamadas. Um serializador consulta muitos dados por função; isso não significa que a serialização deva entrar no modelo. Pergunta que módulo deve conhecer e proteger a regra.
Uma classe de dados pode ser adequada para transportar uma resposta ou representar um valor. Torna-se um problema quando regras do mesmo conceito ficam espalhadas pelos clientes. Uma operação transferir pode pertencer a uma conta; uma vista que consulta o saldo continua a ter uma responsabilidade distinta.
Esta palestra usa exemplos em Ruby para treinar o olhar para smells:
Aplica a pergunta ao Java: qual a alteração que seria difícil no desenho atual e que pequeno passo a tornaria mais simples?
Mudanças dispersas e dependências
Divergent Change ocorre quando mudanças de naturezas diferentes exigem editar a mesma classe. Shotgun Surgery ocorre quando uma mudança exige pequenas edições em muitas classes. O primeiro sugere separar responsabilidades; o segundo pode sugerir juntar uma regra que ficou dispersa.
Outros sinais ajudam a procurar a causa:
Temporary Field: um campo só tem significado durante certas fases e exige verificações constantes.Message Chains: um cliente conhece um percurso comoarena.getHeroi().getPosicao().getX(), ficando ligado à estrutura interna.Middle Man: uma classe só delega sem esconder uma decisão útil. Confirma antes se representa uma fronteira ou contrato necessário.Speculative Generality: abstrações e parâmetros para alternativas que não existem e aumentam o esforço de leitura.Dead Code: código sem uso que já não faz parte do contrato.
Um switch não é sempre um smell. Investiga quando a mesma decisão sobre tipo se repete em muitos módulos ou cresce sempre que acrescentas uma alternativa. Nesse caso, polimorfismo pode localizar cada comportamento.
Como refatorar em segurança
- Descreve o comportamento a preservar e corre os testes existentes.
- Se faltam casos relevantes, acrescenta-os antes da transformação.
- Faz uma transformação pequena, como extrair um método.
- Compila e corre os testes. Inspeciona efeitos e erros que os testes não cobrem.
- Repete até a responsabilidade ficar no local pretendido.
Ferramentas do IDE ajudam a atualizar referências, mas não conhecem todas as regras do domínio. Ao mover um método, confirma a ordem de chamadas, as exceções, o estado partilhado e a visibilidade.
Exemplo: extrair a regra do preço
Neste exemplo próprio, cada unidade custa 200 cêntimos até cinco unidades; as seguintes custam 150. Aceitamos de zero a mil unidades. O preço é um inteiro em cêntimos, por isso não introduzimos arredondamentos de vírgula flutuante.
A primeira versão completa mistura a regra de preço com a impressão. Guarda-a como Main.java se quiseres compará-la localmente:
public class Main {
static void imprimir(int unidades) {
if (unidades < 0 || unidades > 1000) {
throw new IllegalArgumentException("quantidade invalida");
}
int preco;
if (unidades <= 5) {
preco = unidades * 200;
} else {
preco = 5 * 200 + (unidades - 5) * 150;
}
System.out.println(preco);
}
public static void main(String[] args) {
imprimir(0);
imprimir(3);
imprimir(6);
}
}
As saídas esperadas vêm da regra: zero custa 0, três custam e seis custam .
A transformação separa extração, mudança de responsável e simplificação; além dos três casos acima, conserva as fronteiras: 5 unidades custam 1000, 1000 custam 150250, e -1 e 1001 lançam IllegalArgumentException. A mensagem também se mantém. Os casos vêm da tarifa e do intervalo permitido, não da fórmula nova.
Passo 1, extrair sem mudar a fórmula. Move o cálculo tal como está para calcular, com a mesma ramificação:
static int calcular(int unidades) {
if (unidades < 0 || unidades > 1000) {
throw new IllegalArgumentException("quantidade invalida");
}
int preco;
if (unidades <= 5) {
preco = unidades * 200;
} else {
preco = 5 * 200 + (unidades - 5) * 150;
}
return preco;
}
imprimir passa a System.out.println(calcular(unidades));. Compara os casos válidos e inválidos nas duas versões: os valores, o tipo de exceção e a mensagem têm de coincidir. Os três casos do main não exercitam as exceções.
Passo 2, mudar o responsável sem mudar valores. Cria Tarifa.calcular com esse corpo e delega. Os clientes passam a partilhar uma regra única; o comportamento continua igual.
Passo 3, simplificar a fórmula preservando resultados. Só agora introduz constantes e a forma sem ramos:
final class Tarifa {
private static final int LIMITE = 5;
private static final int PRECO_INICIAL = 200;
private static final int PRECO_ADICIONAL = 150;
static int calcular(int unidades) {
if (unidades < 0 || unidades > 1000) {
throw new IllegalArgumentException("quantidade invalida");
}
int iniciais = Math.min(unidades, LIMITE);
int adicionais = Math.max(0, unidades - LIMITE);
return iniciais * PRECO_INICIAL + adicionais * PRECO_ADICIONAL;
}
}
public class Main {
static void imprimir(int unidades) {
System.out.println(Tarifa.calcular(unidades));
}
public static void main(String[] args) {
imprimir(0);
imprimir(3);
imprimir(6);
}
}Executa o código para ver a saída e os gráficos.
Voltamos a obter 0, 600 e 1150. Para zero a cinco unidades, adicionais é zero e iniciais é a quantidade. Acima de cinco, iniciais fica em cinco e adicionais é o excesso. Mantivemos a rejeição fora do intervalo e a mesma mensagem de erro.
Não alterámos a unidade monetária nem o desconto. Trocar a fórmula por uma que aplica 150 a todas as unidades acima de cinco produziria 900 para seis e seria uma mudança de regra, não uma refatoração.
Outras transformações
Extract Variable dá um nome a uma expressão difícil. Preserva quantas vezes ela é calculada quando há efeitos ou estado que muda. Extrair uma chamada do interior de um ciclo para fora pode mudar o resultado.
Guard Clauses tratam casos que terminam cedo e deixam o percurso normal visível. Mantém a ordem de verificações se ela decide qual erro acontece primeiro.
Move Method aproxima uma regra dos dados que ela precisa. Começa por criar o método no destino e delegar do local antigo; depois atualiza clientes se isso fizer parte da mudança.
Encapsulate Collection impede clientes de contornar as operações do dono. Devolver uma cópia ou uma vista não modificável pode resolver problemas diferentes: uma vista não modificável ainda pode refletir mudanças na coleção original.
Replace Conditional with Polymorphism separa alternativas que variam. Só o faz quando as alternativas têm um contrato comum útil. O objetivo é reduzir a propagação de mudanças, não eliminar toda a condição do programa.
Exercícios
Distinguir simplificação de mudança de tarifa
A tarifa aceita de 0 a 1000 unidades. As primeiras cinco custam 200 cêntimos cada; as adicionais custam 150. Duas propostas pretendem simplificar só o cálculo:
// A
Math.min(n, 5) * 200 + Math.max(0, n - 5) * 150
// B
n * (n > 5 ? 150 : 200)Qual preserva a tarifa para todas as quantidades válidas? Justifica por intervalos e dá um contraexemplo para a outra. O que deve acontecer à validação e à mensagem de erro durante a refatoração?
Pista
Solução
A preserva a regra. Para 0 a 5, min(n, 5) dá n e max(0, n - 5) dá zero. Para 6 a 1000, a primeira parcela é 1000 e a segunda cobra só as unidades adicionais. Com n = 6, A dá 1150 e B dá 900: B aplica retroativamente 150 a todas as unidades. Mantemos a rejeição de -1 e 1001, o tipo IllegalArgumentException e a mensagem original. Uma fórmula equivalente no domínio válido não autoriza aceitar entradas que antes eram rejeitadas.
Confere a tua resposta:
- Apresentei uma quantidade acima de cinco para a qual B muda o preço.
- Justifiquei as parcelas da versão A nos dois intervalos.
- Conservei a validação de 0 a 1000 e os erros observáveis.
A ordem pode fazer parte do comportamento
Um método verifica primeiro nome vazio e depois idade negativa, lançando erros diferentes. Uma refatoração troca a ordem. Quando a entrada viola ambos, o comportamento está preservado?
Pista
Mais uma pista
Solução
Percorre a entrada que viola ambos: antes, o erro é de nome; depois da troca, o erro é de idade. Não necessariamente. A mesma entrada pode receber um erro diferente. Conserva a ordem se o contrato a exige ou trata a mudança de regra separadamente.
- Sim, porque continua a lançar uma exceção.. O erro observado pode mudar, incluindo tipo e mensagem.
- Não necessariamente, porque pode mudar o primeiro erro apresentado.. A ordem de validação pode fazer parte do contrato.
- Sim, porque guard clauses não mudam comportamento.. Uma transformação só é refatoração se preservar o comportamento relevante.
Erros frequentes
Contar chamadas chega?
Um serializador consulta dez atributos de uma entidade para construir texto. Um colega propõe movê-lo para a entidade porque tem mais chamadas à entidade do que a si próprio. Avalia a justificação.
Pista
Mais uma pista
Solução
A contagem é um sinal insuficiente. Se o método só traduz a entidade para um formato externo, o serializador pode ser o responsável certo. Mover o formato para a entidade pode juntar razões de mudança independentes. Se o método calcula uma regra do domínio, investiga essa parte separadamente.
Confere a tua resposta:
- Não decide pela contagem de chamadas.
- Distingue regra do domínio de formato de serialização.
- Explica que mover o formato pode introduzir uma responsabilidade independente na entidade.
Erros frequentes
Feature Envy não obriga a mover todo o código que consulta muitos getters.
