en DESARROLLO DE SOFTWARE

Refactoring legacy code

El desarrollo de software es la lucha constante del programador por mantener un diseño simple en su software mientras los requisitos aumentan, cambian o se rectifican.

Una de las técnicas más eficaces para conseguirlo es mantener un ciclo de TDD, que consiste en escribir un test, escribir el código necesario para pasar ese test, y repasar el diseño del código reestructurándolo en caso de ser necesario. A éste último paso se le conoce normalmente como refactorizar.
Se suele hablar de que hay que “refactorizar sin piedad”. Esto es, de que no hay que dejar que la deuda técnica de tu software crezca antes de eliminarla, sino que hay que eliminarla en cuanto se detecte.

La pregunta es: ¿y qué pasa cuando tenemos toneladas de código legacy que necesitamos refactorizar y los refactors no son para nada triviales?

Lo primero: ¿qué entendemos por código legacy? La definición más sencilla es la que más me gusta: código sin test.

Lo segundo: ¿estamos seguros de que necesitamos refactorizar? Para esto hay que hacerse dos preguntas más: ¿es este refactor económicamente viable para el cliente? ¿qué valor le aporta? y ¿nos hemos planteado la posibilidad de reescribir el código desde cero?

Respecto al primer punto, habrá que hacer una primera exploración de la profundidad del refactor para estimar cuantas horas nos puede llevar completarlo y por cuantas personas. Y luego habrá que ver qué recibe el cliente a cambio de ello. También vale plantearse que obtendrá el equipo de desarrollo por realizar ese refactor. Si el equipo está siendo ralentizado por código sucio, quizá limpiarlo pueda hacer que futuros desarrollos cuesten mucho menos tiempo.

Para hacer una exploración del tamaño del refactor, funciona muy bien el crearte una rama en tu control de versiones, y ponerte a usar “extract method” como si no hubiera un mañana sin cubrir previamente de test. Al convertir todo en métodos pequeños, en seguida aflora tanto el Feature Envy como el Primitive Obsession. De ahí pueden salirte muchas clases nuevas, cada una con responsabilidades mucho más acotadas y hacerte una idea de qué hacía ese trozo de software realmente. Puedes descartar todos los cambios y borrar la rama, o reintegrar las nuevas clases creadas con la idea de utilizarlas cuando empieces el refactor de verdad.

Respecto a reescribir el código desde cero, si se trata de un código que siempre tuvo errores y que generaba failure demand me plantearía rehacerlo desde cero. Si se trata de una lógica no muy compleja y fácil de entender, también. El problema es que muchas veces estamos ante código sucio que ha sobrevivido al paso del tiempo porque al fin y al cabo cumplía con su función.

Con lo que llegados a este punto, hemos decidido que el refactor es economicamente viable tanto para el cliente como para la empresa que desarrolla el software y hemos descartado la posibilidad de reescribir el código desde cero.

Lo primero que hay que preguntarse es: ¿cómo vamos a probar el nuevo software? Porque como dice Michael Feathers en Working effectively with legacy code hay dos posibilidades: edit and pray or cover and refactor. Y el “cover” no vale con cubrir con tests unitarios, deberemos cubrir a nivel de integración y, sobre todo, funcional.

Para esto creo que es especialmente útil mantener el código anterior y el actual, y no empezar a refactorizar machacando el código existente. Dos técnicas nos pueden ayudar a esto: Feature toggles y Branch by abstraction.

Una vez que tenemos diseñada nuestra estrategia de probar para asegurarnos que todo va como debe y que el nuevo código, antes de añadir nuevas funcionalidades, va a hacer lo mismo que el viejo código, encontraremos muchos problemas a la hora de poder probar el código. La mayoría de problemas vendrán por no poder liberar al código de dependencias externas y probarlo de manera unitaria.

Una vez más Feathers nos da una serie de técnicas que describo aquí brevemente y que he agrupado libremente (sin seguir el orden del libro):

  • aislar creando interfaces y luego crear doble:
    • adapt parameter: que un parámetro no dependa de una implementación concreta sino de una interfaz
    • extract implementer / extract interface: separar una interfaz de su implementación
    • encapsulate global references: crear una clase que tenga todas las dependencias de variables globales de otra clase e inyectarsela.
  • lidiar con static
    • expose as static method: para poder testear un método sin instanciar la clase, hacerlo static.
    • introduce instance delegator: crear un método no estático llamado por el método estático y con su lógica.
  • machacar dependencia con un nuevo método:
    • introduce static setter: settear una dependencia directamente.
    • parametrize method: añadir un nuevo método que llame a otro existente, pero añadiendo un parametro que settee un atributo de clase.
    • parametrize constructor: idem de lo anterior pero en el constructor.
  • break out “method object”: una de mis técnicas preferidas. Si tienes una clase de 6000 lineas y quieres refactorizar un método de 600 lineas, saca el método a una clase nueva que no tenga nada más. Primero conseguirás ver las dependencias que tiene ese método del resto de la clase, y segundo sólo necesitarás instanciar/mockear esas dependencias para testear el método.
  • subclass and override:
    • extract and override method / override getter: para aislar un código y luego extender la clase para stubbearlo. Se puede usar en combinación de técnicas como replace global reference with getter. Lo mismo para factory methods.
    • pull up feature / push down dependency: lo mismo pero para dependencias directas.

Por supuesto, lo ideal es leerse el libro de Michael Feathers para entender en profundidad las técnicas para romper dependencias, pero espero que publicar mis experiencias estos últimos meses con el ayude a alguien por ahí.  :-)

Edito el post y añado unas notas que me he encontrado por ahí:

  • no empezar el macrorefactor hasta tener claro que nada va a impedirte terminarlo (saber que lo vas a poner en producción)
  • hacer un commit por cada cambio. Y cada cambio puede ser: “eliminados comentarios de la clase”, “good naming de métodos”, “extraido método de acceso a ficheros”. Te tiene que salir más o menos un commit cada veinte lineas. Es importante saber volver atrás en tu control de versiones por si tu refactor va en mala dirección para poder rectificar
  • ¡Nunca diseñes sólo! Y como refactorizar es diseñar, hazlo siempre en pareja. Cuidado porque el pairing mal hecho hará todavía más caro el refactor. Para controlar esto, timeboxea el refactor y dale un tiempo límite: por ejemplo, un número de pomodoros.
  • Como me enseñó el señor Carlos Ble, trata de empezar identificando los DTO´s para eliminar la Primitive Obsession más básica.

Escribe un comentario

Comentario

  1. Gran resumen!
    Yo añadiria tambien “Wrap method”, “sprout method”, “sprout class” y “wrap class”.
    Y luego mis amigos los DTOs, que no se si los cita Michael porque todavia tengo el libro a medias ;-)