featured image

El diseño de software es uno de los aspectos menos tratados en general y sin embargo, uno de los más importantes. Cuando nos enfrentamos a un código hecho por otras personas o por nosotros mismos hace mucho tiempo terminamos etiquetándolo como legado (legacy code), a mi me pasó hace poco con un juego, leprechaun, pero ¿por qué sucede esto?

El código legado se emplea principalmente como atributo peyorativo para el código hecho por otros y que no alcanzamos a entender, o si tenemos un espíritu algo más crítico, para nuestro propio código hecho hace mucho tiempo y que hicimos sin un diseño apropiado y ahora nos cuesta entender.

En mi caso he querido hablar de este tema bastantes veces y aún no tuve oportunidad. Siempre he experimentado este problema con código hecho por o para algún cliente y no quería publicar esa información. No es ni legal ni ético. Pero encontré un juego que desarrollé en Elixir en 2019, de una forma muy táctica y que tenía muchas carencias. Lo tomaré como ejemplo para la corrección de un código con deuda técnica adquirida.

Sobre calidad interna de un software, no hace mucho corregí un artículo algo antiguo que habla de este tema y también otro hablando de deuda técnica. No creo que haga falta repetir el mismo texto aquí, pero sí debemos hablar del diseño de software.

Diseño de Software

En los textos de las Universidades aún enseñan en Ingeniería del Software el Diseño como el paso posterior al análisis y previo a la implementación. Esa idea, un poco manida de encontrar el diseño como un refinamiento de qué se quiere implementar para descubrir cómo implementarlo antes de comenzar a implementar quedó desfasada hace décadas.

En la programación ágil, muchos sostienen la idea del diseño emergente. Es un concepto curioso y correcto, pero debemos tener cuidado en no caer en la trampa de no realizar trabajo de diseño en absoluto y, con la excusa de mantener un diseño emergente, realizar una programación táctica en lugar de estratégica. Esto solo nos llevaría a contraer deuda técnica.

El problema de contar con diseño como una fase aislada es la desconexión del término con respecto a la implementación. Se toma al diseño como la forma de realizar una arquitectura a alto nivel y deja los detalles de la implementación para más adelante. Sin embargo, el sistema donde se implementa influye de forma decisiva en el diseño e incluso el diseño en la forma de refinar los requisitos. Es imposible mantener el diseño en una fase aislada. Debe hacerse diseño tanto cuando se están escribiendo los requisitos o se está analizando el problema como cuando se implementa el código.

Lo ideal es realizar diseño mientras hacemos el análisis y mientras hacemos la implementación. Desde el punto de vista del análisis no es complicado porque consiste en pensar cada requisito, cada modificación o cada acción a implementar dentro del diseño íntegro de la solución. Sería como partir de un lienzo vacío. Pensamos en el primer requisito y cómo se organizaría su implementación de una forma coherente. Al principio, el primer requisito no tiene problema alguno, es fácil. Cuando llega el segundo requisito es cuando toca revisar si encaja de forma coherente con lo ya existente.

Al tener que introducir un nuevo elemento en un diseño podemos errar por falta de tiempo, cuidado o conocimiento y agregar complejidad innecesaria en ese punto del diseño. Esta complejidad hará más difícil las futuras modificaciones y decrementará poco a poco la calidad interna del código. Si con un diseño simple se puede cometer un fallo así, es normal pensar que en un diseño complejo, la complejidad se elevará con mayor frecuencia.

Ahora vayamos al código de Leprechaun. Este código se hizo con dos interfaces, una interfaz web y una interfaz de consola. El juego está contenido en un módulo y aislado del resto. El diseño no se tuvo en cuenta, fue un código que se hizo en muy poco tiempo y solo con el objetivo de tener un juego y no un software que mantener.

Análisis de la calidad

Hay muchas herramientas que nos pueden ayudar antes siquiera de comenzar a leer un código para comprender mejor su nivel de calidad interna. Aunque el código que vamos a revisar es relativo a Elixir, estas herramientas existen en la mayoría de lenguajes.

En resumen:

  • Pruebas, inexistentes.
  • Analizador de discrepancias, errores en tipos.
  • Analizador de código estático, avisos importantes. Fácil de corregir. Quedan avisos a atender.
  • Documentación, inexistente.

Con estos cuatro parámetros podemos determinar una calidad interna del código muy baja. Además de esto tenemos otros indicadores como:

  • Carga cognitiva o la cantidad de información a tener en mente cuando realizamos modificaciones, correcciones o adaptaciones. Este valor también es muy alto porque todo se concentra en muy pocos módulos. Cada módulo contiene muchas responsabilidades.
  • Capacidad de ser probado o la facilidad de escribir pruebas. Al estar todo tan concentrado es muy difícil realizar pruebas.

Podemos ver que la calidad del código es muy baja. ¿Cuál sería el coste de implementar una nueva característica?

Al no existir documentación ni pruebas y tener un nivel de carga cognitiva tan alto, una modificación requeriría de la lectura y entender cómo funciona la parte afectada donde se quiere agregar la nueva característica. Si procedemos haciendo el cambio sin más y realizando pruebas manuales en una dinámica de Editar y Rezar (Michael Feathers), no podemos estar seguros de cuándo podría estar lista la característica o en el peor de los casos sería dar por finalizada la tarea y a lo largo de los siguientes días ir abarcando peticiones de correcciones.

El impacto en una modificación de un día requerirá un día extra para comprender qué se necesita modificar, en qué parte del código hacer la modificación y hacer todas las pruebas manuales además de potencialmente tener que invertir uno o dos días extra en corregir fallos que puedan derivarse de romper algo accidentalmente. En total, estamos usando un tiempo entre 2 y 4 veces superior al necesario. Esa es nuestra deuda técnica.

Vamos a echar un vistazo un poco más detallado a las herramientas que nos permiten obtener una mayor calidad interna.

Pruebas (o Tests)

Una forma de concretar que nuestro código funciona y hace lo que se espera que haga es corroborar las pruebas. La mayoría de programadores que quieren realizar un buen trabajo intentan no solo proporcionar un conjunto abundante de pruebas que atestigüen el buen funcionamiento de un código, sino también una cobertura de código muy alta.

La cobertura de código corresponde al porcentaje de líneas de código ejecutadas durante las pruebas. Con una cobertura de 100% tenemos la certeza de que hay un número de pruebas suficiente para asegurar la ejecución de todos los recovecos del código escrito.

Leprechaun no tenía pruebas. Mala señal.

Analizador de discrepancias

En un lenguaje de tipos dinámicos este tipo de herramientas simula una compilación basada en tipos. Tarda mucho pero realiza un análisis bastante exhaustivo de los datos empleados en cada función y sus posibles incoherencias. En lenguajes con tipos estáticos esta herramienta es parte del compilador.

Al ejecutar Dialyxir (la herramienta disponible para Elixir) destapó 5 errores, algunos muy simples de corregir y otros algo más complejos:

lib/leprechaun/http.ex:51:no_return
Function handle/2 has no local return.

Esta función Leprechaun.Http.handle/2 en realidad no es el problema. El mensaje indica un fallo en esa función pero no sabe determinar cuál. Rápidamente podemos ver que hay otras funciones a las que llama del código y vemos una con otro problema:

lib/leprechaun/http.ex:54:pattern_match
The pattern can never match the type.

Pattern:
{:ok, _req}

Este es el fallo real. Ambos fallos son corregidos al corregir este fallo. Sin embargo queda otro error muy complicado de esclarecer:

lib/leprechaun/game.ex:1:pattern_match
The pattern can never match the type.

Pattern:
false

Type:
true

Podemos apreciar el fallo en la definición del módulo. En verdad, se refiere a un error provocado por una macro que corresponde a la forma en la que se define una guarda. En este punto dejé el problema y lo retomé al momento de hacer una refactorización para poder tener el código más accesible y poder determinar mejor dónde estaba el fallo.

Formato del código

Cuando comencé a programar, recuerdo que al obtener código de otros siempre lo formateaba con mi estilo antes de comenzar a leerlo. Cuando comencé a escribir código no tenía Internet, leía código de libros y escribía mucho código por mi cuenta, tenía un estilo propio y leía rápido mi estilo pero me costaba leer código en otros estilos diferentes. Prefería perder algo de tiempo formateando el código de otros antes de leerlo.

Herramientas como mix format nos ahorran este trabajo permitiendo mantener un único formato en el código. El código de Leprechaun tenía un formato pero no el que suelo emplear ahora. Con esta herramienta corregí este problema en tan solo un segundo.

La herramienta no solo nos permite mantener un estilo único para el proyecto. También podemos emplear varias configuraciones si preferimos leer el código en otro formato diferente, para trabajar a nuestro estilo y tras realizar las modificaciones pertinentes, volvemos al estilo del proyecto antes de realizar la subida del código al repositorio de la empresa.

Analizador de código estático

Esta herramienta realiza un análisis del código en base a una serie de buenas prácticas. No solo emite un aviso en posibles errores de diseño sino también en partes del código que pueden ser complejas de entender. Este tipo de herramienta está disponible en muchos otros lenguajes.

La ejecución de Credo arrojó dos avisos importantes a revisar. Bastante fáciles de corregir. Dejamos pendientes 8 errores de legibilidad (documentación de algunos módulos).

Documentación

Proporcionar documentación es importante por muchos motivos, algunos de los más importantes son:

  • Escribir las decisiones de implementación y el diseño escrito de cada módulo y cada función. Esto tiene una doble ayuda, si empleamos un IDE podemos obtener información por parte del IDE al escribir el nombre de la función y así saber qué parámetros necesitamos pasar y el retorno que recibiremos.
  • Permite pensar un poco más profundamente en la implementación. Cuando necesitamos explicar qué vamos a hacer o qué hemos hecho nos obligamos a darle un sentido. Si no encontramos el sentido es un claro síntoma de estar actuando de forma táctica en lugar de forma estratégica.
  • Entender las interacciones complejas entre elementos. Uno de los elementos más complejos en el sistema es la interacción a través de eventos. Cuando se realiza un movimiento una gran cantidad de eventos es lanzada desde el backend hacia la interfaz donde se está jugando. Si no podemos reducir su complejidad porque es necesaria, debemos documentar el proceso para que no resulte tan difícil.

La herramienta doctor nos ayuda indicando qué documentación nos falta proporcionar ya sea de un módulo, una función o un tipo. Al hacer una refactorización es una buena idea comenzar por aquí. También ex_doc nos puede ayudar a mostrar la documentación en un formato enriquecido y más amigable.

Reparaciones: incrementar la calidad

Después del análisis inicial, queda claro el nivel bajo de calidad del proyecto. Aún no hemos comenzado a analizar lo escrito, solo estamos dándole vueltas al código con las herramientas disponibles. Ahora es momento de hacer una aproximación a qué hay dentro. En un análisis de caja negra nos conviene hacer un par de acciones importantes:

  • Documentar el funcionamiento o el diseño del módulo a modificar.
  • Realizar pruebas en todas sus fronteras o bordes.

La documentación del funcionamiento nos puede ahorrar muchos errores al malinterpretar el código y entender que hace una acción específica para después descubrir que no era así. En este caso, cuando comencé a realizar las pruebas me pasó este problema de creer que los eventos eran de una cierta forma y después descubrir que estaba equivocado.

Por último, pero en verdad la parte más importante es desarrollar un buen conjunto de pruebas. Al desarrollar las pruebas podemos encontrar algunos problemas, por ejemplo que el módulo de juego abarca demasiadas responsabilidades y por ende es muy complejo. Además, está tan encapsulado que tan solo se puede probar como una caja negra. Una vez tenemos una cobertura del código a refactorizar podemos proceder a cambiar el código.

Es importante tratar de no cambiar el comportamiento del sistema mientras se realizan las pruebas. No obstante, en mi caso tuve que modificar la parte en la que se decide qué pieza se selecciona para llamar a un sistema que proporcionara una lista determinista de piezas en lugar de piezas aleatorias. Estos cambios se pueden realizar únicamente si son uno a uno y muy pequeños. Cambiar muchos elementos de una sola vez y de gran tamaño puede conducirnos a romper el comportamiento de nuestro código.

Con un nivel de documentación mínimo y pruebas tenemos una calidad de código mucho mayor. Podemos seguir.

Reparaciones: reducir complejidad

La complejidad no es posible crearla ni destruirla, simplemente podemos disolverla lo suficiente para que sea asumible a lo largo de diferentes módulos. En nuestro caso tenemos Leprechaun.Game. Este módulo tiene varias responsabilidades:

  • Mantiene y gestiona los procesos de los juegos.
  • Lógica del juego. A través de una máquina de estados acepta acciones y genera eventos.
  • Gestión de consumidores. Todos los procesos a los que enviar los eventos del juego.
  • Mantiene y gestiona el tablero de juego.
  • Genera una nueva pieza aleatoriamente.

Si pensamos en disolver la complejidad del módulo, podemos crear los módulos:

  • Leprechaun.Game como el borde o frontera donde acceder a las funciones de la API del juego.
  • Leprechaun.Game.Worker para el proceso de la máquina de estados que mantiene la lógica del juego.
  • Leprechaun.Game.Event para la gestión de consumidores o la publicación de eventos, según se vea.
  • Leprechaun.Game.Board para el tablero donde agregar todas las funciones para manejar el tablero de una forma abstracta.
  • Leprechaun.Game.Board.Piece para la generación de las piezas y poder tanto agregar piezas aleatorias como otras a petición.

La idea es mantener las pruebas funcionando contra la interfaz que sigue proporcionando Leprechaun.Game e ir agregando los nuevos módulos con nuevos tests que proporcionen específicamente pruebas para los comportamientos individuales.

Vale, esto sería lo ideal, no obstante en los cambios que estoy realizando me he basado en amortizar con un interés más bajo el código. Esto quiere decir que aunque la estructura de módulos arriba mostrada es la ideal, vamos a intentar minimizar el impacto creando de momento:

  • Leprechaun.Game como el borde o frontera agregando las funciones de la máquina de estados y la gestión de consumidores.
  • Leprechaun.Board para el tablero y acceder a este de forma abstracta.
  • Leprechaun.Board.Piece para la generación de las piezas.

Así solo faltaría invertir un poco más de tiempo más adelante para extraer cada uno de los elementos anteriores. De esta forma la cantidad de complejidad queda reducida dentro de cada uno de los módulos.

Además, restringimos el uso de send_to/2. Esta función sigue presente únicamente dentro de Leprechaun.Game y es pasada como parámetro a las funciones de Leprechaun.Board susceptibles de emitir eventos. Esta inversión de control nos permite inyectar la función anónima a emplear y facilita mucho las pruebas. Podemos ver el uso muy claramente en las pruebas desarrolladas para Leprechaun.Board:

    # test/leprechaun/board_test.exs:118
    test "increment maximum kind", data do
      parent = self()
      f = &send(parent, &1)

      board =
        data.board
        |> Board.incr_kind(8, f)
        |> Board.incr_kind(9, f)
        |> Board.incr_kind(10, f)

      # test/leprechaun/board_test.exs:139
      assert_receive {:new_kind, 2, 1, 9}
      assert_receive {:new_kind, 2, 2, 9}
      assert_receive {:new_kind, 2, 4, 9}
      assert_receive {:new_kind, 2, 1, 10}
      assert_receive {:new_kind, 2, 2, 10}
      assert_receive {:new_kind, 2, 4, 10}
      refute_receive _, 500

Creamos una función anónima f aceptando el valor necesario para el evento y lanzando ese valor al propio proceso (self()) para tomarlo con assert_receive.

Reparaciones: modelo de dominio

Vale, otro de los problemas que cometí durante la elaboración de la primera versión del código fue mi falta de ingenio al nombrar cosas. Como decía en otro artículo sobre modelo de dominio y lenguaje ubicuo, nombrar cosas es un arte difícil pero debe ser consistente. En el módulo original Leprechaun.Game podemos ver la función check/7 y check/4 pero haciendo diferentes acciones que no tenían que ver con solo la comprobación.

Este es un gran error, el nombre check sugiere que se realizará una comprobación y sin embargo, estas funciones hacen cambios en el tablero. Además de ser un nombre demasiado ambiguo. Estas funciones terminaron tras la refactorización dentro del módulo Leprechaun.Board como find_matches/1 y find_adjacents/4, esta vez sin realizar modificaciones dejando la tarea de las modificaciones a apply_matches/2. Cada función con su correspondiente documentación.

Agregar nuevas características

Ahora sí, tras trabajar en obtener una mejor calidad, podemos concretar la realización de dos nuevas características.

  • Trébol, cuando se consigue juntar 5 o más piezas iguales la siguiente pieza a salir en el tablero será un trébol. Al juntar un trébol con cualquier otra pieza, todas las piezas de este tipo del tablero son cambiadas por un tipo superior.
  • Leprechaun, cuando se juntan 3 o más calderos de arcoíris la siguiente pieza es la del leprechaun. Al juntar el leprechaun con cualquier otra pieza, este se lleva todas esas piezas de ese tipo del tablero pagando sus puntos.

Estas características presentan los siguientes problemas:

  1. Debe poder inyectarse una pieza para ser la siguiente en aparecer en el tablero (trébol).
  2. Al mover un trébol o un leprechaun, el movimiento debe darse por válido y actuar según el caso.
  3. Debe poder modificarse un tablero para incrementar un tipo de pieza a su siguiente.
  4. Debe poder indicar las piezas como match según su tipo en lugar de adyacencia.

En el código anterior, cada una de estas modificaciones habría requerido una cantidad de bifurcaciones peligrosa en el código. Tan solo cambiar la pieza aleatoria por un conjunto de piezas a extraer habría requerido modificar el estado para agregar las futuras piezas. En la refactorización llevamos la responsabilidad de este hecho Leprechaun.Board.Piece.

El caso de los últimos puntos son llevados directamente a ser implementados en el tablero (Leprechaun.Board) y la dinámica de los movimientos es lo único que queda en la lógica del juego.

Realizando la refactorización en este caso la modificación obtiene los siguientes beneficios:

  • La carga cognitiva es menor. Cuando realizamos los cambios en cada módulo no requerimos tener la información de todo, solo lo que necesitamos implementar.
  • La deuda técnica disminuye. El nuevo código sigue los lineamientos fijados tras la creación de las pruebas y la primera refactorización. Incrementamos el código pero también la cobertura al proporcionar pruebas nuevas para el nuevo código.
  • La posibilidad de que aparezcan errores no esperados es mucho menor.

Conclusiones

Aún nos queda bastante para tener el código perfecto. Tenemos un plan para documentar y seguir el trabajo pero podemos ir haciéndolo poco a poco pagando nuestros intereses cada vez que haya que modificar el código agregando un poco más de tiempo para realizar algún cambio de los pendientes.

Podemos ver que inicialmente y debido a una decisión táctica no había diseño alguno. Todo el diseño inicial era fruto de la necesidad y aunque fue emergiendo poco a poco al desarrollar el código, tras obtener la interfaz web como punto culminante del desarrollo, el diseño a nivel general quedó muy pobre.

En esta última interacción y debido a que he pensado de una forma más estratégica he podido planificar mejor cómo evolucionar el diseño. No obstante, no ha sido un todo o nada sino que va a seguir progresando de una forma emergente y probablemente en un par de años no haya llegado nunca al formato planteado sino que tome otro diferente. Porque, ¿qué pasaría si decido integrar Phoenix Framework? En ese caso los eventos podrían emplear Phoenix PubSub y no tendría sentido emplear el modulo Event tal y como se concibe aquí.

Y ahora tú, ¿has tenido que enfrentarte a refactorizaciones o código legado que había sido desarrollado de forma táctica? ¿quizás evolucionó mal por culpa de algunas malas decisiones o trabajos rápidos que acumularon una deuda que nunca se amortizó? ¡Déjanos tu comentario!