No post anterior, falamos sobre como diminuir a complexidade do código através do Design, mas existe uma outra técnica que também deve ser aplicada constantemente e junto com o design que efetivamente reduzirá a complexidade: o REFACTORING.
O que é Refactor?
Refactor é o que pode ser chamado de uma manutenção adaptativa de um código. É uma atividade que não adiciona e nem altera as funcionalidades existentes, mas muda a estrutura de um módulo para deixar ele mais fácil de se manter.
Mas porque devemos alterar o software se não estamos nem adicionando, nem alterando e muito menos removendo funcionalidades dele?
Como já falamos anteriormente, em Por que escrever um bom código?, sistemas podem morrer quando eles não são mais compreensíveis ou quando não conseguimos evoluir eles. E esses sistemas podem morrer pela condução errônea do desenvolvimento, ao se adicionar funcionalidades sem nenhuma preocupação com manutenibilidade, ou pelo seu envelhecimento natural deste. Irei até enfatizar algo que muito poucos falam: sistemas naturalmente envelhecem. Isso acontece mesmo que não façamos nada com eles.
Como um sistema envelhece?
Vamos imaginar um caso hipotético de um sistema que está há bastante tempo em produção, e usa um conjunto de bibliotecas. Se essas bibliotecas não forem atualizadas, vulnerabilidades vão sendo encontradas nelas e isso pode ser um problema que force uma atualização. Não pense que isso é uma realidade distante, aconteceu recentemente com o Log4J, biblioteca amplamente usada e nem sempre atualizada [1].
Atualizar bibliotecas, as vezes, é uma atividade trivial. Basta selecionar o último patch da versão usada e fazer o build da aplicação novamente. Mas quando o software vai se tornando antigo, versões antigas das bibliotecas deixam de receber atualizações e essas dependências se tornam um problema. Somos obrigados a migrar para versões mais recentes ou mesmo substituir essas bibliotecas. Isso pode gerar muito retrabalho, mesmo em um software que não está recebendo novas funcionalidades.
Podemos dizer que: todo software que está em uso, está em evolução. Ele pode não receber atualizações, mas com certeza há uma necessidade para isso. O engenheiro Manny Lehman propos uma série de leis que monstram como isso acontece [2]. Um software, se está em uso, está em mudança continua, em crescimento continue e sofrendo com o aumento de complexidade e um declinio na qualidade.
| Lei da mudança contínua | O sistema deve se adaptar constantemente ou perderá efetividade ao longo do tempo. |
|---|---|
|
Lei do crescimento contínuo |
O sistema precisa continuar se desenvolvendo para manter a satisfação dos usuários. |
|
Aumento da complexidade |
A menos que os desenvolvedores intervenham, o sistema se tornará cada vez mais complexo. |
|
Declínio da qualidade |
A qualidade do sistema diminuirá a menos que os desenvolvedores o adaptem rigorosamente às mudanças no ambiente operacional. |
|
Conservação da familiaridade |
À medida que o sistema evolui e cresce, os profissionais envolvidos, incluindo desenvolvedores e usuários, devem manter sua expertise para garantir o sucesso da evolução. |
|
Conservação da estabilidade organizacional |
A taxa média de trabalho dos profissionais ao utilizar um sistema em evolução deve permanecer constante para garantir a estabilidade organizacional. |
Então, refatorar é uma atividade preventiva que tende a reduzir o impacto do aumento da complexidade e do declinio da qualidado no software. Nós fazemos refactoring para evitar que o software morra, deixando constantemente apto para receber novas funcionalidades.
Então vamos falar dos beneficios do refactor?
Os beneficios do refactoring
Agora que eu te convenci que software evelhece e mesmo que não estejamos adicionando funcionalidades o código vai cada vez ficar mais complexo, quero mudar um pouco o tema. Agora é a hora de responder a pergunta: o que eu ganho refatorando código? e para isso vou contar uma história que ilustre os beneficios da refatoração apontados por Martin Fowler. No seu livro Refatoração, Martin Fowler aponta quatro grandes beneficios:
-
Melhora o design do software
-
Deixa o software mais fácil de entender
-
Ajuda a encontrar bugs
-
Ajuda a programar mais rapidamente
Eu e o protocolo OBEX
No longínquo ano de 2006, eu recebi o código da implementação de um protocolo para instalação de aplicativos nos celulares da Motorola. Se você é novo tudo vai parecer muito distante uma vez que não estávamos falando de Androids e iOS, mas do Symbia. A Motorola, na época, era a maior vendedora de celulares do mundo. Podemos dizer que seria o que a Apple é hoje. O Symbian, por sua vez, era o sistema operacional para celulares mais usado e é tudo tão distante que nem de smartphones estamos falando. Ele era usado para feature phones (só fala ligação, tem agenda e o jogo da cobrinha), media phone (já dá pra ouvir uns MP3, mas vídeo… tá longe!!!) e alguns poucos smart phones. Os smart phones estavam surgindo e eu trabalhava no projeto que iria disponibilizar a IDE para as pesssoas fazerem aplicativos para eles.
Nós já tinhamos alguns projetos com Symbian, um deles nós criamos uma "IDE" para fazer temas para o V3, que era o celular mais vendido e o mais chique que existia. Tudo que ia em um Symbian era instalado via um protocolo chamado OBEX.
O protocolo havia sido implementado para uma ferramenta interna da Motorola que era muito usada nos testes, mas nós precisavamos integrar ele em uma ferramenta para o desenvolvimento de themas, skins e aplicações. Ao pegar o código, ele poderia ser muito bem descrito pela alcunha de spaghetti code. Era um real espaguete de funções chamando funções sem o controle bem definido do estado interno, tudo em uma classe só.
O OBEX era um protocolo binário para interação com o dispositivo, ou seja, era bem dificil de entender o que estava acontecendo. E o deploy da aplicação seguia um roteiro de comandos usando o OBEX. Vários comandos deveriam ser enviados para o celular para saber se havia uma aplicação, se deveria desinstalá-la, enviar a nova versão, atualizar e finalizar a conexão. Quando recebi o zip com as classes Java, tudo parecia funcionar bem, mas haviam bugs e bugs sérios. A implementação não funcionava corretamente e a cada deploy, o celular precisava ter sua conexão USB (nem sei se era USB) reiniciada. Ou seja, faz um deploy, depois remove o cabo e coloca de novo. Comportamento aceitável para uma ferramenta interna, mas já dá pra supor que alguém tentou consertar, não conseguiu e entregou daquele jeito.
Funciona, legal! Não! Apesar desse bug ser tolerável dentro de uma empresa, o produto que eu trabalhava iam para desenvolvedores. Os usuários seriam pessoas que não são obrigadas a usar aquela ferramenta e ficar reiniciando uma conexão USB é um porre! Precisávamos consertar o erro, mas tinha um grande problema: o código era gigantesco e ilegível.
Antes de agir, é preciso estratégia!
E foi com esse enorme desafio que comecei a pensar em uma estratégia e o primeiro passo foi estudar a especificação tanto do celular quanto do OBEX. Descobrimos que o processo de instalação deveria funcionar como uma série de comandos onde o resultado de um geraria atualização ou instalação da aplicação no dispositivo. Abaixo eu desenho um diagrama de como poderia ser o processo. É obvio que o diagrama não reflete a realidade! Faz quase 20 anos que executei essa atividade, não vou me lembrar desses pormenores.
Já que identificamos esse comportamento de comandos sendo enviados, resolvemos aplicar o padrão Command e fazer com que a operação que desejariamos fosse uma simples sequência de comandos sendo enviados. Isso, por si só, reduziria a complexidade ao separar a lógica do envio dos comandos da construção dos comandos. Então criamos uma classe abstrata chamada ObexCommand e colocamos todo o código de gerar o comando a partir dos parâmetros deles. Também removemos os controles de leitura e escrita da conexão em classes separadas. Haveria uma classe para se comunicar com o dispositivo, abriria a conexão, inicializaria os buffers de entrada e saída, enviaria o comando e leria a resposta, passando a mesma ao comando. Mas se você observou bem a imagem, o comando não resolveria o problema porque a sequência de comandos pode mudar de acordo com a respostas dos mesmo.
Por isso adaptamos o padrão Comando para funcionar como o State. Cada comando geraria os próximos comandos a serem executados em sequência baseado no estado da resposta. Faríamos uma Máquina de Estados Finitos, que curiosamente não está descrito no GoF.
Com essa modelagem básica, chegou a hora de refatorar. Pegamos cada trecho de código e movemos para as novas classes. O código em si não iria mudar, só onde ele estava colocado. Cada comando deveria receber alguns parâmetros, gerar o binário, processar uma resposta com a sua saída. A class ObexCommand deveria ser bem simples, composta de um método para gerar o comando em binário e um método para processar a resposta, uma vez que os comandos são síncronos.
public abstract class ObexCommand {
abstract byte[] serialize();
abstract List<ObexCommand> process(ObexResponse response);
}
O executor dos comandos também seriam simples. Se fosse hoje eu chamaria de OperationExecutor porque receberia uma classe abstrata chamada Operation. Depois, o protocolo foi usado para outras coisas dentro da ferramenta, mas seu primeiro uso foi para fazer a instalação/atualização. Observe o código do OperationExecutor. Ele é facilmente compreensível. Sabemos exatamente como funcionaria a execução de uma operação completa.
public class OperationExecutor {
public ObexResponse process(Operation operation) {
// Inicializa a lista de comandos em ordem e a resposta
Queue<ObexCommand> commands = new LinkedList<>(operation.startCommands());
ObexResponse operationResponse = null;
// Enquanto houver comandos para executar da operação,
// Envia, processa resposta e adiciona novos comandos na lista
while (!commands.isEmpty()) {
ObexCommand currentCommand = commands.poll();
operationResponse = execute(currentCommand);
commands.addAll(currentCommand.process(resp));
}
// a lista está vazia. Operação concluida!
return operationResponse;
}
}
De resto, precisava somente implementar a classe de cada comando que poderiam ser chamadas de ConnectCommand, CheckPreviousAplicationInstallCommand, UninstallApplicationCommand, SendFileCommand, InstallApplicationCommand e CloseConnectionCommand. Cada classe é simples e possui uma responsabilidade bem definida.
Para nossa surpresa, ao apenas copiar os trechos de códigos e adaptar as novas interfaces, a operação começou a funcionar plenamente sem nenhum bug!!!!! Ainda foi preciso debugar o código antigo para entender o bug porque naquela época o processo de resolução de bugs exigia uma analise Post Mortem e descobrimos que ao processar uma das respostas, um comando esquecia de escrever alguns caracteres 0 que seriam o marcador binário em específico. Isso fazia com que o dispositivo não escrevesse a resposta do comando uma vez o esperado marcador não era encontrado. Eu, obviamente, não lembro que comando era e isso nem é importante, Mas ao separar comando a comando conseguimos verificar qual comando estava sendo escrito, o que existia na lista e qual era a resposta esperada. MAS isso foi só para a análise, porque isso foi automaticamente corrigido com a refatoração.
Queria contar essa experiência porque ela foi muito elucidativa na minha história sobre como uma refatoraçõa me fez encontrar um bug e especifico e ela me ajuda a explicar o próximo beneficio.
Eu contei essa história porque ela descreve como uma atividade de refatoração bem elaborada pode desencadearam uma serie de ganhos para o projeto.
Ajuda a encontrar bugs
Uma boa refatoração ajuda a encontrar bugs. No caso que narrei, o bug foi resolvido quase que automaticamente. Só no trabalho de mover o código para dentro das classes da nova modelagem, de se ter um real modelo, ele foi corrigido. O grande problema do código era que tudo era um emaranhado de funções, uma chamado a outra com o controle de estado feito por uma classe com muitas responsabilidades, ou pior, uma responsabilidade genérica.
Melhora o Design do Software
O outro ganho foi que o design realmente começou a ser palpável. Aquele componente começou a se parecer mais com algo estruturado e não com um processo sem design.
Surgiu a ideia de comando, que já estava na documentação, mas não era refletida no código. Com ela se separou cada comando enviado e o código comum entre eles ficou em uma classe comum.
Surgiu a ideia de estado. E o estado da execução do comando foi separado do estado da conexão. De certa forma, também adicionamos a ideia de camadas. A comunicação USB era um stream de entrada e saída, mas esse stream passou a ser entendido como comandos síncronos sendo enviados. O controle do estado do stream foi separado do estado dos comandos.
Deixa o software mais fácil de entender
Outro ganho foi que o código ficou muito fácil de entender. A grande responsabilidade de "fazer o deploy da aplicação" foi quebrada em diversas pequenas responsabilidades de fácil entendimento. Qualquer classe que fosse aberta, era possível se compreender como ela se encaixava no processo e o processo era descrito por uma classe bem simples representada pelo código de OperationExecutor.
Ajuda a programar mais rapidamente
E por fim podemos falar do ganho final. Essa refatoração nos ajuda a programar mais rapidamente. Se surgisse uma nova atividade para adicionar a opção de DEBUG da aplicação não era necessário alterar o complexo código com mais um monte de IF/ELSE. Bastava criar uma nova operação com novos comandos. Isso seria muito menos custoso que alterar um código totalmente espaguete.
O Martin Fowler chega a criar um gráfico mostrando que um software com um bom design pode ter mais funcionalidades que um com um design ruim. Isso acontece porque em determinado momento do projeto ficará tão caro dar manutenção no software que não sobrará dinheiro e esforço para evoluir o mesmo. O software será um peso, dificil de manter e impossível de se adicionar novas funcionalidades. Eu já trabalhei em diversos projetos que o escopo era reescrever um software já existente para que ele pudesse receber novas funcionalidades, e creio que isso não é muito incomum.
Quando refatorar?
Agora vamos para a próxima pergunta que devemos fazer: quando devemos refatorar? Para mim, a resposta é simples: sempre! Mas isso as vezes não é óbvio. Diversas vezes eu pego pull requests para revisar em que o desenvolvedor adiciona 50 linhas em um método sem nenhuma vergonha. E quando eu pergunto porque ele não refatorou, eles me respondem sem cerimônia: porque a atividade não envolvia refatoração.
Isso pra mim é um mal entendimento das responsabilidades do desenvolvedor. Vamos imaginar que estamos falando de um cozinheiro. E ao cozinheiro é dada a tarefa de cozinhar, mas não se dá a tarefa de limpar. Não estamos falando de uma limpeza pesada, faxina, mas da limpeza de bancada e ferramentas. Não sei se você já viu um cozinheiro profissional, ele sempre está com sua bancada limpíssima, com as ferramentas alinhadas e com seu paninho em mão, sempre limpando a bancada. Esse é um hábito dentro da gastronomia.
Porque não é um hábito dentro do mercado de desenvolvimento de software normalizar qualidade como um requisito. Talvez porque nunca vemos o que acontece por detrás dos panos. O código é um produto que muitas vezes se usa, mas não se lê. Até memes são comuns sobre isso.
Outra suposição é que nossos cursos são muito preocupados em nos capacitar a aprender a usar uma linguagem, mas nunca em sermos realmente bons desenvolvedores. Isso já é um problema apontado desde 1971 quando Niklaus Wirth falou em seu artigo "Program development by stepwise refinement" que somos ensinados a escrever código, mas não a escrever programas. Ou seja, aprendermos como usar uma linguagem de programação, mas não somos ensinados como entender um problema e criar um programa que o resolva.
Mas, essa é uma resposta muito genérica. Quero detalhar diversos momentos em que podemos refator, mas com objetivos diferentes.
Refatoração preparatória
Sabe quando você recebe uma nova funcionalidade para implementar e percebe que ela aumentará a complexidade do módulo. É um excelente momento para testar novos design que facilitem o desenvolvimento da atividade.
Você terá que pensar no novo design que reduza o tempo de implementação da funcionalidade. Essa abordagem é boa porque a refatoração é inserida dentro da atividade, diluida como um hábito e não como uma atividade extra. Pense como se estivesse limpando a bancada antes de cozinhar.
O resultado também é bom porque dependendo do resultado você entregará um código com um melhor design e com menos complexidade.
Refatoração para compreensão
Sabe aquele momento que você pega um módulo com zero compreensão dos detalhes de implementação e algumas funcionalidades a serem implementadas? No esforço de tentar entender os trechos mais complexos, vale uma refatoração. Na melhor das hipóteses você vai entregar um código mais compreensível e na pior das hipóteses você compreenderá o código não descartará o trabalho de refatoração.
A ideia é tentar entender o código brincando com ele, alterando o design. No exemplo que falei acima, eu usei esse tipo de refatoração e o resultado foi incrivelmente positivo. Foi tão bom que consigo me lembrar depois de 20 anos.
Refatoração para coleta de lixo
Em alguns momentos do desenvolvimento vemos que algo ficou desorganizado. Não seria esse o melhor momento para organizar a bagunça? Eu gosto muito da regra do escoteiro, que eu li no fabuloso Manual dos Escoteiros Mirins: sempre deixe o acampamento mais limpo do que recebeu.
Aliás, se você pratica Test-Driven Development, você deve ter percebido que essa regra está codificada dentro do processo. No processo, o desenvolvimento é feito em três etapas: (i) na primeira fazemos um teste falhar alterando a validação de acordo com a nova regra de negócio, (ii) depois alteramos o código para que todos os testes passem e (iii) por fim refatoramos o código. Refatorar é uma constante dentro do TDD.
Refatoração planejada
Sabe quando o time de desenvolvimento entende que há a necessidade de uma refatoração, mas ela não é pontual e não pode ser inserida dentro de uma atividade? Porque não planejar ela como uma atividade diluida que possa ser encarada por diversas pessoas da equipe?
Refatoração em Code Review
Recebeu uma Pull Request para revisar e não entendeu nada? Já coloca como uma atividade refatorar. Os momentos de Code Review são excelentes para se refatorar código. É o momento em que outros olhos vão entender o que foi feito.
Porque não sugerir novos designs? Porque não apontar dificuldades de compreensão? Porque não apontar que a complexidade está crescente.
Um módulo não fica complexo do dia pra noite magicamente. Ele passa por diversas code reviews em que pequenas alterações são feitas em trechos cada vez mais complexos.
Como convencer meu gerente que devo refatorar?
Na verdade nem sempre precisamos convencer nossos gerentes. Infelizmente refatorar é uma atividade que gera repulsa dos gerentes porque eles imaginam que mais trabalho será feito, enquanto na verdade o trabalho pode ser o mesmo ou até menor.
Agir com estratégia evita retrabalho no futuro e esse é um método inteligente de se trabalhar. Mas não precisamos informar que vamos fazer. Você pode apenas dizer que vai melhorar o código para continuar com a implementação.
Conclusão
Refatorar é uma atividade primordial para que um software evolua com qualidade. Refatore sempre, questione o design sempre, pense em modos melhores de se escrever o código sempre!
Isso não é um peso ou mais trabalho. Um código bem escrito fará com que menos trabalho seja necessário.