Inversión de control

Algo que continuamente me estoy preguntando al escribir código es ¿Será esta la mejor manera de organizarlo? Y es que realmente escribo mucho código, y eso quiere decir que tengo que organizar mucho pinche código.

Principalmente trato de alcanzar estos objetivos al echar el código:

  • Que funcione,
  • que sea fácil de entender,
  • que sea fácil de mantener.

Recientemente me encontré aportando un poco a cierto bot de telegram y eso me llevó a pensar un poco sobre valores globales, efectos secundarios y flujo de control. Hablemos primero sobre efectos secundarios.

Efectos secundarios

Absolutamente todo el código que vas a escribir tiene efectos secundarios. Tu código interactúa con el mundo, y esa en interacción se utilizan mecanismos de comunicación que hacen que las funciones que escribas, o una porción de ellas, no puedan ser estrictamente puras. Cualquier cosa desde la más simple entrada del usuario por consola o un print hasta los más sofisticados protocolos de red son efectos secundarios.

En ejemplos:

def funcion_pura(username, password):
    return ("POST", {
        "username": username,
        "password": password,
        "remember": True,
    })

BACKEND_URL = 'https://miapi.com/login'

def funcion_con_efectos_secundarios(username, password):
    log.debug("Logging in as", username)

    http.post(BACKEND_URL, {
        "username": username,
        "password": password,
        "remember": True,
    })

La primera función es pura porque su valor de retorno depende únicamente de sus argumentos y toda su comunicación con el mundo exterior se da por medio de sus argumentos (que van a ser su entrada) y su valor de retorno (que es su salida).

La segunda función por otro lado no tiene valor de retorno, lo cual por sí mismo nos dice que su salida es

  1. modificando alguno de sus parámetros o
  2. por medio de efectos secundarios.

Además toma un valor de una variable global. Esto es en si una entrada de la función sin embargo no figura en sus argumentos.

El problema con los efectos secundarios

Entonces me preguntarás, si todo el código que voy a escribir va a tener efectos secundarios ¿por qué preocuparse por ellos? Y yo te diré:

Principalmente por dos razones.

La primera es que las funciones con efectos secundarios son más difíciles de enender y analizar, pues hay que considerar todas las posibles entradas de información que no vengan de sus parámetros y todas las salidas que no vengan de su valor de retorno. La presencia de efectos secundarios ata la función a su contexto y para entenderla a cabalidad hay que también entender a cabalidad su contexto. Si varias funciones con efectos secundarios coexisten entonces están potencialmente entrelazadas y comprender el funcionamiento de todo el sistema requiere tener en cuenta los efectos secundarios y sus interrelaciones.

Las funciones puras por el contrario pueden ser analizadas en aislamiento, nada entra a ellas que no sea un argumento y nada sale que no sea un valor de retorno, se pueden estudiar individualmente y componer entre sí para crear comportamientos complejos que definen el funcionamiento del sistema entero.

La segunda es que las funciones con efectos secundarios son más difíciles de probar (probar del inglés test).

La esencia de una prueba unitaria es que, dada una entrada de la función, puedas comprobar que su salida es la deseada.

Si pensamos en una función con efectos secundarios encontraremos que para controlar su entrada tenemos que controlar no solo sus argumentos sino los posibles valores que esté tomando del entorno, como las variables globales. Y si queremos verificar su salida tenemos que considerar no solo su valor de retorno sino los posibles efectos que pueda estar teniendo en el contexto, ya sea la salida estandar, protocolos de red etcétera. Ambas cosas pueden ser muy difíciles de hacer según qué tan interconectadas estén las funciones que conforman el sistema.

La tercera (¿qué no habías dicho que eran dos?) Es que el código lleno de funciones con efectos secundarios es más difícil de refactorizar, pues al modificarlo potencialmente se están rompiendo las frágiles relaciones entre las funciones que describen el comportamiento del programa, al punto de que podrían romperlo. Esto sumado a una falta de pruebas unitarias y de integración son la fórmula para el desastre, la deuda técnica y la tercera guerra mundial.

Si esto te suena, cuidado

Una forma facilísima de organizar el código es la siguiente:

Diagrama que describe un flujo tradicional de diseño de software.

Sin importar si escribimos código orientado a objetos o imperativo habrá funciones llamándose las unas a las otras, lo importante es si los efectos secundarios están sucediendo ahí escondidos profundo en la pila de llamadas.

Esta forma de organizar el código es intuitiva de pensar (ya de por sí escribir código es un reto) y permite solucionar problemas bastante rápido. El problema es el flujo de la información.

Si los efectos suceden en lo más profundo de la pila de llamadas quiere decir que hay información necesaria que está fluyendo desde arriba (el punto de entrada [1]) hasta abajo (donde suceden los efectos) o en el peor de los casos, que hay un mecanismo para acceder a la información necesaria para los efectos "directamente" desde lo más profundo de la pila de llamadas, aunque esa información viene de arriba (ejem. variables globales).

En esta forma de organizar el código la lógica y los efectos secundarios no tienen una barrera clara, las funciones que lo describan difícilmente serán puras y se puede padecer de los vicios que describo arriba.

¿Y ahora cómo le hacemos?

Invirtiendo el flujo de la información.

Diagrama que representa mi propuesta de organización del flujo ideal de la información en el software.

Mi propuesta es organizar las cosas de tal manera que la lógica primordial del software esté definida por funciones puras [2] o por lo menos aislado de efectos secundarios y estos últimos estén organizados en un nivel superficial, cerca del punto de entrada del programa. En el mejor de los casos incluso una porción importante de las dependencias están confinadas a ese nivel superficial y son fáciles de intercambiar.

De esta manera, la mayor parte de nuestro código al comportarse como una función pura haría fluir la información necesaria como parámetros y la devolvería procesada como valores de retorno, que después serían usados para realizar los efectos secundarios que concretan el fin del flujo del software. También al concentrar los efectos secundarios en una misma capa sería más fácil entender el efecto que tiene el programa en el mundo exterior, y corregirlo, extenderlo o refactorizarlo de ser necesario.

De la misma manera la lógica, al no depender de la capa de efectos secundarios, puede refactorizarse, modificarse u optimizarse sin modificar los efectos del programa. Esto acompañado de pruebas ayudaría a garantizar que el código siempre se desempeñe como debería.

¿Es esto realmente posible?

Como con todas las cosas, no te diría que vayas a tu código y lo empieces a reescribir ahorita mismo, sino que analices esta propuesta con cuidado, y determines si tiene sentido. Muchas veces pasa que un software es difícil de probar por padecer de los vicios que describo antes, pero que modificándolo un poco con esta estructura se le pueden añadir las pruebas necesarias para garantizar su funcionamiento.

El código sin pruebas está roto por defecto.

Principalmente en software muy complejo es cuando comienza a cobrar sentido. Un buen momento para aplicar esto es justo cuando, al añadir nuevas características, se pasa de "un programita sencillo" a "una maraña de código".

¿Por qué detenerse ahí?

En realidad no hay por qué detenerse a nivel de un programa, sino que podemos pensar esto más allá, hacia infraestructuras completas, en las que cada servicio se comporte como una función sin estado a la cual entra información por un (y solo un) lado, y sale transformada por el otro. Quizá pueda ahondar más en esto en otra entrada.

¿Qué opinas de Clean Architecture?

Al presentar una plática sobre este tema en @xalapacode alguien mencionó Clean Architecture. Las ideas que aquí presento son muy similares y giran en torno a la misma idea: separar menesteres de la aplicación. Sin embargo el enfoque que sigo está completamente centrado en los efectos secundarios como factor para identificar claramente las capas a separar. A partir de ahí se pueden implementar otros esquemas que vayan teniendo sentido.

Conclusión

Siempre sé consciente de los efectos secundarios que tiene tu código, y trata de organizarlo teniéndolos en mente. Rechaza toda tentación de una variable global, o de modificar un parámetro que llega por referencia a una función y en su lugar considera definir la lógica primordial en torno a funciones puras. Y añade pruebas, muchas pruebas.

Finalmene me gustaría mencionar que a partir de estas ideas podemos organizar las dependencias de nuestro código de forma tal que no dependamos tanto de ellas. Esto llega a ser de mucha utilidad al momento de reemplazarlas y puede ser vital para mantener a raya la deuda técnica, pero eso es tema de otra entrada del blog.

Notas al pie

[1]Menciono un par de veces sobre el punto de entrada pero no especifico a qué me refiero con ello. Se trata del iniciador del programa. Por ejemplo en una aplicación web podría ser el index.php o wsgi.py, o en una aplicación de escritorio/consola la función int main(). Cualquiera que sea el iniciador de la aplicación o el punto principal de control de la misma es el punto de entrada. Generalmente en este (o cerca de) se define cómo entra la información al software. Ya sea mediante un protocolo de red, la entrada estándar o entrada de usuario mediante una interfaz.
[2]Hablas mucho de funciones puras, pero no de programación funcional, ¿por qué? Si bien considero importante (y hasta fundamental) aprender un lenguaje funcional, también entiendo que en muchos casos no es posible usarlo, ya sea porque el proyecto usa otros lenguajes que no se pueden cambiar o por preferencia personal. Creo que podemos perseguir principios como la inmutabilidad y manejar funciones puras en nuestros lenguajes estructurados y orientados a objetos sin necesitar hacer el salto completo y aun así tener los beneficios.