¿Cómo evito las refactorizaciones en cascada?

52

Tengo un proyecto En este proyecto, deseé refactorizarlo para agregar una característica, y refactoré el proyecto para agregar la característica.

El problema es que cuando terminé, resultó que necesitaba hacer un pequeño cambio de interfaz para acomodarlo. Entonces hice el cambio. Y luego la clase consumidora no se puede implementar con su interfaz actual en términos de la nueva, por lo que también necesita una nueva interfaz. Ahora son tres meses después, y he tenido que solucionar innumerables problemas virtualmente no relacionados, y estoy buscando resolver problemas que fueron trazados durante un año a partir de ahora o simplemente enumerados como que no se solucionarán debido a la dificultad antes de que la cosa se compile de nuevo.

¿Cómo puedo evitar este tipo de refactorizaciones en cascada en el futuro? ¿Es solo un síntoma de que mis clases anteriores dependen demasiado entre sí?

Edición breve: en este caso, el refactor fue la característica, ya que el refactor aumentó la extensibilidad de un fragmento de código en particular y disminuyó el acoplamiento. Esto significaba que los desarrolladores externos podían hacer más, que era la característica que quería ofrecer. Entonces el refactor original en sí mismo no debería haber sido un cambio funcional.

Edición más grande que prometí hace cinco días:

Antes de comenzar este refactorizador, tenía un sistema donde tenía una interfaz, pero en la implementación, simplemente hice dynamic_casttodas las implementaciones posibles que envié. Obviamente, esto significaba que no podía simplemente heredar de la interfaz, por un lado, y en segundo lugar, que sería imposible para cualquiera sin acceso de implementación implementar esta interfaz. Así que decidí que quería solucionar este problema y abrir la interfaz para el consumo público para que cualquiera pudiera implementarla y que implementar la interfaz fuera todo el contrato requerido, obviamente una mejora.

Cuando estaba encontrando y matando con fuego todos los lugares donde había hecho esto, encontré un lugar que resultó ser un problema particular. Depende de los detalles de implementación de todas las diversas clases derivadas y la funcionalidad duplicada que ya se implementó pero mejor en otro lugar. Podría haberse implementado en términos de la interfaz pública y reutilizado la implementación existente de esa funcionalidad. Descubrí que requería un contexto particular para funcionar correctamente. En términos generales, la implementación previa de la llamada se parecía un poco

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

Sin embargo, para obtener este contexto, necesitaba cambiarlo a algo más parecido

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

Esto significa que para todas las operaciones que solían ser parte de ellas f, algunas de ellas deben formar parte de la nueva función gque opera sin contexto, y algunas de ellas deben formar parte de la parte diferida ahora f. Pero no todos los métodos fllaman necesidad o quieren este contexto, algunos de ellos necesitan un contexto distinto que obtienen por medios separados. Entonces, para todo lo que ftermina llamando (que es, más o menos, casi todo ), tuve que determinar qué contexto, si es que necesitaban, de dónde deberían obtenerlo y cómo dividirlos de lo antiguo fen lo nuevo fy lo nuevo g.

Y así es como terminé donde estoy ahora. La única razón por la que seguí adelante es porque necesitaba esta refactorización por otras razones de todos modos.

DeadMG
fuente
67
Cuando dice que "refactorizó el proyecto para agregar una función", ¿qué quiere decir exactamente? La refactorización no cambia el comportamiento de los programas, por definición, lo que hace que esta declaración sea confusa.
Jules
55
@Jules: Hablando estrictamente, la característica era permitir que otros desarrolladores agregaran un tipo particular de extensión, por lo que la característica era el refactorizador, lo que hacía que la estructura de la clase fuera más abierta.
DeadMG
55
¿Pensé que esto se discute en cada libro y artículo que habla sobre refactorización? El control de la fuente viene al rescate; si encuentra que para hacer el paso A, primero debe hacer el paso B, luego desechar A y hacer B primero.
rwong
44
@DeadMG: este es el libro que originalmente quería citar en mi primer comentario: "El juego" pick-up sticks "es una buena metáfora del Método Mikado. Eliminas la" deuda técnica ", los problemas heredados integrados en casi todos los programas sistema: siguiendo un conjunto de reglas fáciles de implementar. Extraiga cuidadosamente cada dependencia entrelazada hasta que exponga el problema central, sin colapsar el proyecto ".
rwong
2
May, ¿puedes aclarar de qué lenguaje de programación estamos hablando? Después de leer todos sus comentarios, llego a la conclusión de que lo está haciendo a mano en lugar de usar un IDE para ayudarlo. Por lo tanto, me gustaría saber si puedo darle algunos consejos prácticos.
thepacker

Respuestas:

69

La última vez que intenté comenzar una refactorización con consecuencias imprevistas, y no pude estabilizar la compilación y / o las pruebas después de un día , me di por vencido y volví la base de código al punto anterior a la refactorización.

Luego, comencé a analizar lo que salió mal y desarrollé un mejor plan de cómo refactorizar en pasos más pequeños. Entonces, mi consejo para evitar las refactorizaciones en cascada es: saber cuándo parar , ¡no dejes que las cosas se salgan de tu control!

A veces tienes que morder la bala y tirar un día completo de trabajo, definitivamente más fácil que tirar tres meses de trabajo. El día que pierdes no es completamente en vano, al menos has aprendido cómo no abordar el problema. Y según mi experiencia, siempre hay posibilidades de dar pasos más pequeños en la refactorización.

Nota al margen : parece estar en una situación en la que tiene que decidir si está dispuesto a sacrificar tres meses completos de trabajo y comenzar de nuevo con un nuevo plan de refactorización (y con suerte más exitoso). Me imagino que no es una decisión fácil, pero pregúntese, ¿qué tan alto es el riesgo que necesita otros tres meses no solo para estabilizar la construcción, sino también para corregir todos los errores imprevistos que probablemente introdujo durante su reescritura que hizo en los últimos tres meses? ? Escribí "reescribir", porque supongo que eso es lo que realmente hiciste, no una "refactorización". No es improbable que pueda resolver su problema actual más rápido volviendo a la última revisión donde se compila su proyecto y comience con una refactorización real (en lugar de "reescribir") nuevamente.

Doc Brown
fuente
53

¿Es solo un síntoma de que mis clases anteriores dependen demasiado entre sí?

Seguro. Un cambio que causa una miríada de otros cambios es prácticamente la definición de acoplamiento.

¿Cómo evito los refactores en cascada?

En el peor tipo de bases de código, un solo cambio continuará en cascada, lo que eventualmente hará que cambie (casi) todo. Parte de cualquier refactor donde hay un acoplamiento generalizado es aislar la parte en la que está trabajando. Debe refactorizar no solo dónde está tocando este código su nueva función, sino dónde todo lo demás toca ese código.

Por lo general, eso significa hacer algunos adaptadores para ayudar al antiguo código a funcionar con algo que se ve y actúa como el antiguo código, pero que usa la nueva implementación / interfaz. Después de todo, si todo lo que haces es cambiar la interfaz / implementación pero dejar el acoplamiento no estás ganando nada. Es lápiz labial en un cerdo.

Telastyn
fuente
33
+1 Cuanto más se necesita una refactorización, más amplia será la refactorización. Es la naturaleza misma de la cosa.
Paul Draper
44
Sin embargo, si realmente está refactorizando , otro código no debería tener que preocuparse por los cambios de inmediato. (Por supuesto, eventualmente querrás limpiar las otras partes ... pero eso no debería ser requerido de inmediato). Un cambio que "caiga en cascada" en el resto de la aplicación, es más grande que la refactorización, en ese momento es básicamente un rediseño o reescritura.
cHao
+1 Un adaptador es exactamente la forma de aislar el código que desea cambiar primero.
winkbrace
17

Parece que su refactorización fue demasiado ambiciosa. Se debe aplicar una refactorización en pequeños pasos, cada uno de los cuales se puede completar en (digamos) 30 minutos, o, en el peor de los casos, como máximo un día, y deja el proyecto edificable y todas las pruebas aún pasan.

Si mantienes cada cambio individual mínimo, realmente no debería ser posible que una refactorización rompa tu construcción durante mucho tiempo. El peor de los casos probablemente sea cambiar los parámetros a un método en una interfaz ampliamente utilizada, por ejemplo, para agregar un nuevo parámetro. Pero los cambios consecuentes a partir de esto son mecánicos: agregar (e ignorar) el parámetro en cada implementación y agregar un valor predeterminado en cada llamada. Incluso si hay cientos de referencias, no debería tomar ni un día realizar una refactorización de este tipo.

Jules
fuente
44
No veo cómo puede surgir tal situación. Para cualquier refactorización razonable de la interfaz de un método, debe haber un nuevo conjunto de parámetros fácilmente determinables que se puedan pasar que dará como resultado que el comportamiento de la llamada sea el mismo que antes del cambio.
Jules
3
Nunca he estado en una situación en la que quisiera realizar una refactorización de este tipo, pero tengo que decir que me suena bastante inusual. ¿Estás diciendo que eliminaste la funcionalidad de la interfaz? Si es así, ¿a dónde fue? ¿En otra interfaz? ¿O en otro sitio?
Jules
55
Entonces, la forma de hacerlo es eliminar todos los usos de la función que se eliminará antes de refactorizar para eliminarla, en lugar de después. Esto le permite mantener el código en construcción mientras trabaja en él.
Jules
11
@DeadMG: eso suena extraño: está eliminando una característica que ya no es necesaria, como usted dice. Pero, por otro lado, escribe "el proyecto se vuelve completamente no funcional", lo que en realidad suena que la función es absolutamente necesaria. Por favor aclarar
Doc Brown
26
@DeadMG En tales casos, normalmente desarrollaría la nueva característica, agregaría pruebas para asegurarse de que funciona, pasará el código existente para usar la nueva interfaz y luego eliminará la (ahora) característica superflua anterior. De esa manera, no debería haber un punto en el que las cosas se rompan.
sapi
12

¿Cómo puedo evitar este tipo de refactor en cascada en el futuro?

Diseño de ilusión

El objetivo es un excelente diseño e implementación de OO para la nueva característica. Evitar la refactorización también es un objetivo.

Comience desde cero y haga un diseño para la nueva característica que es lo que desea tener. Tómese el tiempo para hacerlo bien.

Sin embargo, tenga en cuenta que la clave aquí es "agregar una función". Las cosas nuevas tienden a dejarnos ignorar en gran medida la estructura actual de la base del código. Nuestro diseño de ilusiones es independiente. Pero luego necesitamos dos cosas más:

  • Refactorice solo lo suficiente para hacer una costura necesaria para inyectar / implementar el código de la nueva característica.
    • La resistencia a la refactorización no debe impulsar el nuevo diseño.
  • Escriba una clase orientada al cliente con una API que mantenga la nueva característica y los códecs existentes felizmente ignorantes entre sí.
    • Translitera para obtener objetos, datos y resultados de un lado a otro. Principio de menor conocimiento sea condenado. No vamos a hacer nada peor de lo que ya hace el código existente.

Heurística, lecciones aprendidas, etc.

La refactorización ha sido tan simple como agregar un parámetro predeterminado a una llamada de método existente; o una sola llamada a un método de clase estática.

Los métodos de extensión en las clases existentes pueden ayudar a mantener la calidad del nuevo diseño con un riesgo mínimo absoluto.

La "estructura" lo es todo. La estructura es la realización del Principio de Responsabilidad Única; Diseño que facilita la funcionalidad. El código será breve y simple en toda la jerarquía de clases. El tiempo para el nuevo diseño se recupera durante las pruebas, el reelaboración y evitar la piratería a través de la jungla de códigos heredada.

Las clases de ilusiones se centran en la tarea en cuestión. En general, olvídate de extender una clase existente: solo estás induciendo la cascada de refactorización nuevamente y tienes que lidiar con la sobrecarga de la clase "más pesada".

Purgue cualquier remanente de esta nueva funcionalidad del código existente. Aquí, la funcionalidad de la nueva característica completa y bien encapsulada es más importante que evitar la refactorización.

radarbob
fuente
9

Del libro (el maravilloso) Trabajar eficazmente con código heredado de Michael Feathers :

Cuando rompe las dependencias en el código heredado, a menudo tiene que suspender un poco su sentido de la estética. Algunas dependencias se rompen limpiamente; otros terminan pareciendo menos que ideales desde el punto de vista del diseño. Son como los puntos de incisión en la cirugía: puede que quede una cicatriz en su código después de su trabajo, pero todo lo que está debajo puede mejorar.

Si luego puede cubrir el código alrededor del punto donde rompió las dependencias, también puede sanar esa cicatriz.

Kenny Evitt
fuente
6

Parece que (especialmente de las discusiones en los comentarios) te has encerrado con reglas autoimpuestas que significan que este cambio "menor" es la misma cantidad de trabajo que una reescritura completa del software.

La solución tiene que ser "no hagas eso, entonces" . Esto es lo que sucede en proyectos reales. Como resultado, muchas API antiguas tienen interfaces feas o parámetros abandonados (siempre nulos), o funciones llamadas DoThisThing2 () que hacen lo mismo que DoThisThing () con una lista de parámetros completamente diferente. Otros trucos comunes incluyen el almacenamiento de información en globales o punteros etiquetados para pasar de contrabando una gran parte del marco. (Por ejemplo, tengo un proyecto donde la mitad de los búferes de audio contienen solo un valor mágico de 4 bytes porque eso fue mucho más fácil que cambiar cómo una biblioteca invoca sus códecs de audio).

Es difícil dar consejos específicos sin un código específico.

pjc50
fuente
3

Pruebas automatizadas. No necesita ser un fanático de TDD, ni necesita una cobertura del 100%, pero las pruebas automatizadas son las que le permiten realizar cambios con confianza. Además, parece que tiene un diseño con un acoplamiento muy alto; Debe leer acerca de los principios SÓLIDOS, que están formulados específicamente para abordar este tipo de problema en el diseño de software.

También recomendaría estos libros.

  • Trabajando eficazmente con código heredado , plumas
  • Refactorización , Fowler
  • Creciente software orientado a objetos, guiado por pruebas , Freeman y Pryce
  • Código limpio , Martin
asthasr
fuente
3
Su pregunta es, "¿cómo evito esta [falla] en el futuro?" La respuesta es que, incluso si actualmente "tiene" CI y pruebas, no las está aplicando correctamente. No he tenido un error de compilación que duró más de diez minutos en años, porque veo la compilación como "la primera prueba unitaria", y cuando está rota, la arreglo, porque necesito poder ver las pruebas pasando como Estoy trabajando más en el código.
asthasr
66
Si estoy refactorizando una interfaz muy utilizada, agrego una cuña. Esta cuña maneja el valor predeterminado, para que las llamadas heredadas continúen funcionando. Trabajo en la interfaz detrás de la cuña, luego, cuando termino, empiezo a cambiar las clases para usar la interfaz nuevamente en lugar de la cuña.
asthasr
55
Continuar refactorizando a pesar de la falla de construcción es similar a un cálculo muerto . Es una técnica de navegación de último recurso . En la refactorización, es posible que una dirección de refactorización sea simplemente errónea, y ya has visto el signo revelador de eso (en el momento en que deja de compilarse, es decir, volar sin indicadores de velocidad del aire), pero decidiste seguir adelante. Finalmente, el avión se cae del radar. Afortunadamente, no necesitamos una caja negra o investigadores para refactorizar: siempre podemos "restaurar al último buen estado conocido".
rwong
44
@DeadMG: usted escribió "En mi caso, las llamadas anteriores simplemente ya no tienen sentido", pero en su pregunta "un cambio de interfaz menor para acomodarlo". Honestamente, solo una de esas dos oraciones puede ser verdadera. Y a partir de la descripción de su problema, parece bastante claro que su cambio de interfaz definitivamente no fue menor . Realmente deberías pensar mucho sobre cómo hacer que tu cambio sea más compatible con versiones anteriores. Según mi experiencia, eso siempre es posible, pero primero debes idear un buen plan.
Doc Brown
3
@DeadMG En ese caso, creo que lo que está haciendo no se puede llamar razonablemente refactorización, cuyo punto básico es aplicar los cambios de diseño como una serie de pasos muy simples.
Jules
3

¿Es solo un síntoma de que mis clases anteriores dependen demasiado entre sí?

Lo más probable es que sí. Aunque puede obtener efectos similares con una base de código bastante agradable y limpia cuando los requisitos cambian lo suficiente

¿Cómo puedo evitar este tipo de refactorizaciones en cascada en el futuro?

Además de detenerse para trabajar en código heredado, no me temo. Pero lo que puede hacer es usar un método que evite el efecto de no tener una base de código de trabajo durante días, semanas o incluso meses.

Ese método se llama "Método Mikado" y funciona así:

  1. escriba el objetivo que desea lograr en una hoja de papel

  2. haz el cambio más simple que te lleve en esa dirección.

  3. compruebe si funciona utilizando el compilador y su conjunto de pruebas. Si continúa con el paso 7. de lo contrario, continúe con el paso 4.

  4. en su nota de papel, las cosas que deben cambiar para que su cambio actual funcione. Dibuja flechas, de tu tarea actual, a las nuevas.

  5. Revierta sus cambios Este es el paso importante. Es contrario a la intuición y duele físicamente al principio, pero como acabas de intentar algo simple, en realidad no es tan malo.

  6. elija una de las tareas, que no tenga errores salientes (sin dependencias conocidas) y vuelva a 2.

  7. cometer el cambio, tachar la tarea en el papel, elegir una tarea que no tenga errores salientes (sin dependencias conocidas) y volver a 2.

De esta manera, tendrá una base de código de trabajo en intervalos cortos. Donde también puedes fusionar los cambios del resto del equipo. Y tiene una representación visual de lo que sabe que todavía tiene que hacer, esto ayuda a decidir si desea continuar con el esfuerzo o si debe detenerlo.

Jens Schauder
fuente
2

Refactorizar es una disciplina estructurada, distinta de limpiar el código como mejor le parezca. Debe tener pruebas unitarias escritas antes de comenzar, y cada paso debe consistir en una transformación específica que usted sabe que no debe hacer cambios en la funcionalidad. Las pruebas unitarias deben pasar después de cada cambio.

Por supuesto, durante el proceso de refactorización, naturalmente descubrirá los cambios que se deben aplicar y que pueden causar roturas. En ese caso, haga todo lo posible para implementar una compatibilidad de compatibilidad para la interfaz anterior que utiliza el nuevo marco. En teoría, el sistema debería funcionar igual que antes, y las pruebas unitarias deberían pasar. Puede marcar la compatibilidad de compatibilidad como una interfaz obsoleta y limpiarla en el momento más adecuado.

200_success
fuente
2

... Refactoré el proyecto para agregar la función.

Como dijo @Jules, refactorizar y agregar características son dos cosas muy diferentes.

  • Refactorizar consiste en cambiar la estructura del programa sin alterar su comportamiento.
  • Agregar una característica, por otro lado, aumenta su comportamiento.

... pero de hecho, a veces necesitas cambiar el funcionamiento interno para agregar tus cosas, pero prefiero llamarlo modificar en lugar de refactorizar.

Necesitaba hacer un pequeño cambio de interfaz para acomodarlo

Ahí es donde las cosas se complican. Las interfaces están pensadas como límites para aislar la implementación de cómo se usa. Tan pronto como toque las interfaces, todo lo que esté a ambos lados (implementarlo o usarlo) también deberá cambiarse. Esto puede extenderse tanto como lo experimentaste.

entonces la clase consumidora no se puede implementar con su interfaz actual en términos de la nueva, por lo que también necesita una nueva interfaz.

Que una interfaz requiera un cambio suena bien ... que se extienda a otra implica que los cambios se extiendan aún más. Parece que alguna forma de entrada / datos requiere fluir por la cadena. ¿Es ese el caso?


Tu charla es muy abstracta, por lo que es difícil de entender. Un ejemplo sería muy útil. Por lo general, las interfaces deben ser bastante estables e independientes entre sí, lo que permite modificar parte del sistema sin dañar el resto ... gracias a las interfaces.

... en realidad, la mejor manera de evitar modificaciones de código en cascada son precisamente buenas interfaces. ;)

dagnelies
fuente
-1

Creo que generalmente no puedes a menos que estés dispuesto a mantener las cosas como están. Sin embargo, cuando situaciones como la suya, creo que lo mejor es informar al equipo y hacerles saber por qué debería realizarse una refactorización para continuar con un desarrollo más saludable. No iría y arreglaría las cosas solo. Lo hablaría durante las reuniones de Scrum (suponiendo que ustedes las tengan), y lo abordaría sistemáticamente con otros desarrolladores.

Tarik
fuente
1
esto no parece ofrecer nada sustancial sobre los puntos hechos y explicados en las respuestas anteriores 9
mosquito
@gnat: Quizás no, pero simplifiqué las respuestas.
Tarik