Como evito refatorações em cascata?

52

Eu tenho um projeto. Neste projeto, quis refatorá-lo para adicionar um recurso e refatorei o projeto para adicionar o recurso.

O problema é que, quando terminei, precisei fazer uma pequena alteração na interface para acomodá-lo. Então eu fiz a mudança. E então a classe consumidora não pode ser implementada com sua interface atual em termos da nova, então ela também precisa de uma nova interface. Agora, três meses depois, tive que corrigir inúmeros problemas praticamente não relacionados, e estou olhando para resolver problemas que foram mapeados por um ano ou simplesmente listados como não corrigidos devido a dificuldades antes que a coisa seja compilada novamente.

Como posso evitar esse tipo de refatoração em cascata no futuro? É apenas um sintoma de minhas aulas anteriores dependendo muito um do outro?

Edição breve: Nesse caso, o refator era o recurso, já que o refator aumentou a extensibilidade de uma parte específica do código e diminuiu alguns acoplamentos. Isso significava que desenvolvedores externos poderiam fazer mais, que era o recurso que eu queria transmitir. Portanto, o próprio refator original não deveria ter sido uma mudança funcional.

Maior edição que prometi cinco dias atrás:

Antes de começar este refator, eu tinha um sistema em que eu tinha uma interface, mas na implementação, eu simplesmente dynamic_cast através de todas as implementações possíveis que eu forneci. Isso obviamente significava que você não poderia herdar apenas a interface, em segundo lugar, e, em segundo lugar, que seria impossível para qualquer um sem acesso à implementação implementar essa interface. Então decidi que queria corrigir esse problema e abrir a interface para o consumo público, de modo que qualquer um pudesse implementá-lo e implementar a interface fosse o contrato inteiro exigido - obviamente, uma melhoria.

Quando eu estava encontrando e matando-com-fogo todos os lugares que eu tinha feito isso, encontrei um lugar que provou ser um problema particular. Dependia dos detalhes de implementação de todas as várias classes derivadas e da funcionalidade duplicada que já estava implementada, mas melhor em outro lugar. Ele poderia ter sido implementado em termos da interface pública e reutilizado a implementação existente dessa funcionalidade. Descobri que é necessário que um determinado contexto funcione corretamente. Grosso modo, a implementação anterior chamada parecia um bocado como

for(auto&& a : as) {
     f(a);
}

No entanto, para obter este contexto, eu precisava mudá-lo para algo mais parecido com

std::vector<Context> contexts;
for(auto&& a : as)
    contexts.push_back(g(a));
do_thing_now_we_have_contexts();
for(auto&& con : contexts)
    f(con);

Isso significa que, para todas as operações que costumavam fazer parte de f , algumas delas precisam fazer parte da nova função g que opera sem contexto, e algumas delas precisam ser feitas de uma parte do f agora diferido. Mas nem todos os métodos f chamam necessidade ou querem este contexto - alguns deles precisam de um contexto distinto que obtenham através de meios separados. Então, para tudo que f acaba chamando (o que é, grosso modo, praticamente tudo ), eu tive que determinar qual contexto eles precisavam, de onde eles deveriam obtê-lo, e como dividi-los do antigo f no novo f e novo g .

E foi assim que acabei onde estou agora. A única razão pela qual eu continuei é porque eu precisava dessa refatoração por outras razões, de qualquer forma.

    
por DeadMG 11.01.2015 / 15:53
fonte

11 respostas

69

Da última vez que tentei iniciar uma refatoração com consequências imprevistas, e não consegui estabilizar a compilação e / ou os testes após um dia , desisti e reverti a base de código até o ponto antes de refatoração.

Depois, comecei a analisar o que deu errado e desenvolvi um plano melhor de como fazer a refatoração em etapas menores. Portanto, meu conselho para evitar refatorações em cascata é apenas: saiba quando parar , não deixe que as coisas saiam do seu controle!

Às vezes você tem que morder a bala e jogar fora um dia inteiro de trabalho - definitivamente mais fácil do que jogar fora três meses de trabalho. O dia que você solta não é completamente em vão, pelo menos você aprendeu como não abordar o problema. E, para minha experiência, existem sempre possibilidades de fazer pequenos passos na refatoração.

Nota: : você parece estar numa situação em que precisa decidir se está disposto a sacrificar três meses completos de trabalho e começar tudo de novo com uma nova (e esperançosamente mais bem-sucedida) refatoração plano. Eu posso imaginar que não é uma decisão fácil, mas pergunte a si mesmo, quão alto é o risco de precisar de outros três meses não apenas para estabilizar a compilação, mas também para corrigir todos os erros imprevistos que você provavelmente introduziu durante sua reescrita você fez os últimos três meses? Eu escrevi "reescrever", porque eu acho que é o que você realmente fez, não uma "refatoração". Não é improvável que você possa resolver seu problema atual mais rapidamente voltando para a última revisão onde seu projeto é compilado e começar com uma refatoração real (em oposição a "reescrever") novamente.

    
por 12.01.2015 / 08:30
fonte
53

Is it just a symptom of my previous classes depending too tightly on each other?

Claro. Uma mudança que causa uma miríade de outras mudanças é basicamente a definição de acoplamento.

How do I avoid cascading refactors?

No pior tipo de base de código, uma única mudança continuará em cascata, fazendo com que você mude (quase) tudo. Parte de qualquer refatorador onde há acoplamento generalizado é isolar a peça em que você está trabalhando. Você precisa refatorar não apenas onde seu novo recurso está tocando esse código, mas onde tudo o mais toca esse código.

Normalmente, isso significa fazer alguns adaptadores para ajudar o código antigo a funcionar com algo que parece e age como o código antigo, mas usa a nova implementação / interface. Afinal, se tudo que você faz é mudar a interface / implementação, mas deixar o acoplamento você não está ganhando nada. É batom em um porco.

    
por 11.01.2015 / 17:53
fonte
17

Parece que sua refatoração era ambiciosa demais. Uma refatoração deve ser aplicada em pequenos passos, cada um dos quais pode ser concluído em (digamos) 30 minutos - ou, na pior das hipóteses, no máximo um dia - e deixa o projeto edificável e todos os testes ainda passam.

Se você mantiver cada alteração individual mínima, não deve ser possível para uma refatoração quebrar sua compilação por um longo tempo. O pior caso é provavelmente alterar os parâmetros para um método em uma interface amplamente utilizada, por ex. para adicionar um novo parâmetro. Mas as mudanças consequentes disso são mecânicas: adicionando (e ignorando) o parâmetro em cada implementação e adicionando um valor padrão em cada chamada. Mesmo que haja centenas de referências, não deve demorar nem um dia para realizar essa refatoração.

    
por 11.01.2015 / 19:16
fonte
12

How can I avoid this kind of cascading refactor in the future?

Design de Pensamento Ansioso

O objetivo é um excelente design e implementação de OO para o novo recurso. Evitando a refatoração também é uma meta.

Comece do zero e crie um design para o novo recurso que você gostaria de ter. Aproveite o tempo para fazer isso bem.

Observe, entretanto, que a chave aqui é "adicionar um recurso". Coisas novas tendem a nos deixar ignorar a estrutura atual da base de código. Nosso design de pensamento positivo é independente. Mas então precisamos de mais duas coisas:

  • Refatorize apenas o suficiente para fazer uma emenda necessária para injetar / implementar o código do novo recurso.
    • A resistência à refatoração não deve impulsionar o novo design.
  • Escreva uma classe voltada para o cliente com uma API que mantenha o novo recurso & codez existente blissfully ignorante de se.
    • É transliterado para obter objetos, dados e resultados de um lado para outro. Princípio de menor conhecimento seja condenado. Não faremos nada pior do que o código existente já faz.

Heurística, Lições Aprendidas, etc.

A refatoração foi tão simples quanto adicionar um parâmetro padrão a uma chamada de método existente; ou uma única chamada para um método de classe estática.

Os métodos de extensão em classes existentes podem ajudar a manter a qualidade do novo design com um risco mínimo absoluto.

"Estrutura" é tudo. Estrutura é a realização do Princípio da Responsabilidade Única; design que facilita a funcionalidade. O código permanecerá curto e simples até a hierarquia de classes. O tempo para o novo design é criado durante os testes, retrabalho e evitando a invasão pela selva de código legado.

As classes de pensamento positivo concentram-se na tarefa em questão. Geralmente, esqueça de extender uma classe existente - você está apenas induzindo o refator em cascata novamente & ter que lidar com a sobrecarga da "classe mais pesada".

Purgue todos os vestígios desta nova funcionalidade do código existente. Aqui, a funcionalidade de novos recursos completa e bem encapsulada é mais importante do que evitar a refatoração.

    
por 11.01.2015 / 22:53
fonte
9

Do (maravilhoso) livro Trabalhando efetivamente com o código legado de Michael Feathers :

When you break dependencies in legacy code, you often have to suspend your sense of aesthetics a bit. Some dependencies break cleanly; others end up looking less than ideal from a design point of view. They are like the incision points in surgery: there might be a scar left in your code after your work, but everything beneath it can get better.

If later you can cover code around the point where you broke the dependencies, you can heal that scar, too.

    
por 13.01.2015 / 05:25
fonte
6

Parece (especialmente a partir das discussões nos comentários) que você se encaixou em regras auto-impostas, o que significa que essa mudança "menor" é a mesma quantidade de trabalho que uma reescrita completa do software.

A solução tem que ser "não faça isso, então" . É isso que acontece em projetos reais. Muitas APIs antigas têm interfaces feias ou parâmetros abandonados (sempre nulos) como resultado, ou funções denominadas DoThisThing2 () que fazem o mesmo que DoThisThing () com uma lista de parâmetros totalmente diferente. Outros truques comuns incluem informações stashing em globals ou ponteiros marcados, a fim de contrabandear-lo passado um grande pedaço de estrutura. (Por exemplo, eu tenho um projeto em que metade dos buffers de áudio contém apenas um valor mágico de 4 bytes, porque isso era muito mais fácil do que mudar a forma como uma biblioteca invocava seus codecs de áudio.)

É difícil dar conselhos específicos sem código específico.

    
por 12.01.2015 / 18:04
fonte
3

Testes automatizados. Você não precisa ser um fanático do TDD, nem precisa de 100% de cobertura, mas testes automatizados são o que permitem que você faça alterações com confiança. Além disso, parece que você tem um design com acoplamento muito alto; você deve ler sobre os princípios do SOLID, que são formulados especificamente para abordar esse tipo de problema no design de software.

Eu também recomendaria esses livros.

  • Trabalhando efetivamente com o código herdado , penas
  • Refatoração , Fowler
  • Desenvolvimento de software orientado a objetos, guiado por testes , Freeman e Pryce
  • Código Limpo , Martin
por 11.01.2015 / 16:26
fonte
3

Is it just a symptom of my previous classes depending too tightly on each other?

Provavelmente sim. Embora você possa obter efeitos semelhantes com uma base de código bastante agradável e limpa quando os requisitos mudam o suficiente

How can I avoid this kind of cascading refactorings in the future?

Além de parar para trabalhar no código legado, você não pode ter medo. Mas o que você pode é usar um método que evite o efeito de não ter uma base de código funcional por dias, semanas ou até meses.

Esse método é chamado "Método Mikado" e funciona assim:

  1. anote o objetivo que você deseja alcançar em um pedaço de papel

  2. faça a mudança mais simples que o leva nessa direção.

  3. verifique se funciona usando o compilador e sua suíte de testes. Se continuar com o passo 7. caso contrário, continue com o passo 4.

  4. em seu papel, anote as coisas que precisam ser alteradas para fazer sua alteração atual funcionar. Desenhe setas de sua tarefa atual para as novas.

  5. Reverter suas alterações Este é o passo importante. É contra-intuitivo e fisicamente dói no começo, mas desde que você acabou de tentar uma coisa simples, não é tão ruim assim.

  6. escolha uma das tarefas, sem erros de saída (sem dependências conhecidas) e retorne a 2.

  7. confirme a alteração, risque a tarefa no papel, escolha uma tarefa que não tenha erros de saída (sem dependências conhecidas) e retorne a 2.

Desta forma, você terá uma base de código de trabalho em intervalos curtos. Onde você também pode mesclar as alterações do restante da equipe. E você tem uma representação visual do que você sabe que ainda tem que fazer, isso ajuda a decidir se você quer continuar com o objetivo ou se deve pará-lo.

    
por 14.01.2015 / 12:25
fonte
2

Refatorar é uma disciplina estruturada, distinta da limpeza do código como você preferir. Você precisa ter testes de unidade escritos antes de iniciar, e cada etapa deve consistir em uma transformação específica que você sabe que não deve fazer alterações na funcionalidade. Os testes de unidade devem passar depois de cada mudança.

Naturalmente, durante o processo de refatoração, você descobrirá naturalmente as mudanças que devem ser aplicadas e que podem causar quebra. Nesse caso, tente o seu melhor para implementar um shim de compatibilidade para a interface antiga que usa o novo framework. Em teoria, o sistema ainda deve funcionar como antes e os testes de unidade devem passar. Você pode marcar o shim de compatibilidade como uma interface obsoleta e limpá-lo em um momento mais adequado.

    
por 12.01.2015 / 08:04
fonte
2

...I refactored the project to add the feature.

Como disse @Jules, Refatorar e adicionar recursos são duas coisas muito diferentes.

  • A refatoração é sobre mudar a estrutura do programa sem alterar seu comportamento.
  • Adicionar um recurso, por outro lado, está aumentando seu comportamento.

... mas, de fato, às vezes você precisa mudar o funcionamento interno para adicionar suas coisas, mas eu prefiro chamá-lo de modificação em vez de refatoração.

I needed to make a minor interface change to accommodate it

É aí que as coisas ficam confusas. Interfaces são entendidas como limites para isolar a implementação de como ela é usada. Assim que você tocar nas interfaces, tudo o que estiver em ambos os lados (implementá-lo ou usá-lo) terá que ser alterado também. Isso pode se espalhar tanto quanto você experimentou.

then the consuming class can't be implemented with its current interface in terms of the new one, so it needs a new interface as well.

Essa interface requer uma mudança que soa bem ... que se espalhe para outra, implica que as mudanças se espalhem ainda mais. Parece que alguma forma de entrada / dados precisa fluir pela cadeia. É esse o caso?

Sua palestra é muito abstrata, por isso é difícil descobrir. Um exemplo seria muito útil. Normalmente, as interfaces devem ser bastante estáveis e independentes umas das outras, tornando possível modificar parte do sistema sem prejudicar o resto ... graças às interfaces.

... na verdade, a melhor maneira de evitar modificações em código em cascata é precisamente as interfaces boas . ;)

    
por 12.01.2015 / 18:30
fonte
-1

Eu acho que você geralmente não pode, a menos que você esteja disposto a manter as coisas como elas são. No entanto, quando situações como a sua, eu acho que o melhor é informar a equipe e informá-las sobre o motivo de algumas refatorações serem feitas para continuar o desenvolvimento mais saudável. Eu não iria apenas consertar as coisas sozinho. Eu falaria sobre isso durante as reuniões do Scrum (supondo que vocês as tenham), e abordasse sistematicamente com outros desenvolvedores.

    
por 14.01.2015 / 04:09
fonte