featured image

El pasado 20 de septiembre de 2021 se publicó una charla clave (keynote) de Saša Jurić hablando sobre la claridad en el desarrollo de software con ejemplos ilustrativos. La charla, ¿le echamos un vistazo?

El vídeo está en Youtube y dura algo más de una hora (en inglés). Por si quieres echarle un vistazo:

Durante el vídeo Jurić nos da un repaso ilustrado sobre conceptos del desarrollo del software en Elixir partiendo de un código de ejemplo:

def run(x) do
  1
  |> List.duplicate(x)
  |> Integer.undigits()
  |> Stream.iterate(fn previous ->
    prefix =
      (previous + 1)
      |> Integer.digits()
      |> List.insert_at(0, 0)
      |> Stream.chunk_every(2, 1, :discard)
      |> Stream.take_while(fn [a, b] -> a <= b end)
      |> Enum.map(fn [_, b] -> b end)

    suffix =
      prefix
      |> List.last()
      |> List.duplicate(length(Integer.digits(previous + 1)) - length(prefix))

    Integer.undigits(prefix ++ suffix)
  end)
  |> Stream.take_while(&(&1 < Integer.pow(10, x)))
  |> Enum.count()
end

En mis charlas siempre intento reflejar la facilidad del código de Erlang y Elixir sobre otros lenguajes. Aquí Jurić nos da un ejemplo sobre lo difícil que puede ser un código en cualquier lenguaje cuando no se escribe con la suficiente claridad.

Al parecer el código se refiere a un algoritmo bastante conocido, si sabes cual es viendo este código, ¡déjanoslo en los comentarios!

Los puntos que Jurić nos muestra y sobre los que habla en el vídeo son los siguientes:

Todo se reduce a Claridad

Aunque muchos intentan acuñar términos como legibilidad, mantenibilidad, calidad o simplicidad, Jurić prefiere emplear el término claridad. Este término es más conciso acerca del objetivo a perseguir. Sin embargo, entiende la subjetividad del término y que cada cual pueda interpretarlo de una forma diferente.

Jurić define el término como:

Lo que buscamos es un trasfondo común porque buscamos como lectores con fluidez en el lenguaje de comunicación la lectura sin esfuerzo razonable en el entendimiento del propósito del programa como cuál es el problema que está resolviendo y la solución que el autor eligió.

La claridad es eficiencia y efectividad

Jurić nos muestra una reescritura del código anterior de una forma lineal donde el orden de complejidad se reduce a O(n) haciéndose mucho más eficiente. No obstante es claro solo porque conocemos el código anterior y ya hemos entendido qué realiza:

def run(length) do
  for step <- 1..(length - 1),
      digit <- 9..1,
      reduce: {9, 8, 7, 6, 5, 4, 3, 2, 1} do
    counts ->
      carry = if digit == 9, do: 0, else: elem(counts, digit)
      count = elem(counts, digit - 1) + carry
      put_elem(counts, digit - 1, count)
  end
  |> elem(0)
end

El objetivo de la claridad en los programas es obtener mayor eficiencia y eficacia. Transmitir la idea de qué se quiere realizar y cómo se realiza de una forma rápida y fidedigna.

Llegados a este punto no me quedó muy clara la relación entre claridad y eficiencia y efectividad. Interpreto que Jurić nos quiere transmitir el concepto de claridad de un programa como una forma eficiente y efectiva de comunicar el problema a resolver por el programa y la solución implementada. Al mismo tiempo que agrega en la ecuación posibles incidencias de no programar de forma clara agregando relaciones del código no obvias o dependencias siendo factores reductores de esta claridad.

La claridad potencia el trabajo en equipo

Cuando trabajamos en un código con alto nivel de claridad cualquier componente del equipo siente la confianza suficiente para trabajar en cualquier parte del código. Igualmente con el movimiento de personal cambiando a otros roles o marchándose de la compañía propicia la inclusión de nuevos desarrolladores y ayuda a extender la mala fama del código heredado en caso de tener código con falta de claridad.

Jurić nos recomienda dedicar esfuerzo a mantener el código claro. Un equipo y cada integrante de un equipo no dedica mucho tiempo a clarificar el código y si no se dedica un tiempo a esta tarea finalmente se termina con una gran cantidad de código legado para los siguientes integrantes del equipo que no querrán tocarlo.

Para evitar en gran medida este problema Jurić propone la realización de revisiones de código. Estas revisiones de código entre compañeros pone la tarea de leer y entender el código realizado por otra persona y así poner en juicio su claridad.

Así mismo, Jurić nos avisa del peligro de la programación por parejas. Dos personas trabajando sobre el mismo problema tendrán el mismo problema sobre claridad del código que una sola persona. Si nuestro objetivo es alcanzar una mayor claridad en la base del código no podemos basarnos solo en la programación por parejas. Debemos acompañarla de la revisión de código por personas ajenas a ese cambio o corrección que se está introduciendo.

Cambios pequeños, revisados y claros

El autor de los cambios debe realizar cambios de código pequeños, peticiones de cambio pequeñas y mantener el código claro. Según Jurić, ese debe ser el máximo enfoque de cada cambio realizado en el sistema: pequeño y claro.

La consecuencia de obtener cambios pequeños claros se traduce en una revisión rápida y una respuesta de alta calidad por parte del resto del equipo. Porque los seres humanos somos bastante malos tratando con grandes cantidades de información de una sola vez.

Si hemos necesitado realizar muchos cambios y hemos generado una historia bastante extensa sobre un cambio, Jurić nos recomienda esconder esta historia a quien debe realizar la revisión. Jurić apunta la lectura del código como un viaje pasando del punto A al punto B. Cualquier desvío puede perder la atención del lector en aspectos no importantes o irrelevantes del viaje y dificultar la lectura. Si podemos rehacer la historia (squash o rebase) es recomendable a mantener todo ahí.

Cuando nos toca realizar revisiones debemos sugerir mejoras, proponer mejoras y hablar con el autor para hablar sobre las ideas y obtener mejores explicaciones. Importante no ser tímidos y lanzarse a proponer cambios y mejoras e incluso realizar refactorizaciones posteriores en otra petición de cambio en caso de que veamos que algo puede ser de otra forma completamente diferente.

Separación de Intereses

Jurić nos introduce la idea de la separación de intereses (separation of concerns), una idea atribuida a Edsger W. Dijkstra y que aparecía originalmente en un ensayo suyo de 1974 llamado On the role of scientific thought (sobre el papel del pensamiento científico). El documento habla sobre programación a alto nivel pero no sobre código fuente en absoluto. Se trata de un documento que habla sobre estilos de pensamiento. Jurić lo resume como:

Cuando él [Dijkstra] estudiaba algún asunto, alguna materia, a él le gustaba verlo desde diferentes puntos de vista, de un aspecto diferente aislado de otros aspectos. Nada revolucionario aquí, esto viene de otros pensamientos aún más antiguos de dividir el problema. Divide y vencerás. Incluso Dijkstra comenta esto mismo al principio del ensayo.

Jurić nos muestra la forma de emplear este concepto. Desde el punto de vista de quién escribe el código lo ideal es escribir este código en pequeños trozos comprensibles por sí mismos y aislados del resto. Veamos un par de ejemplos:

  1. Un usuario puede registrarse con un email y una clave.
  2. Un usuario registrado puede iniciar sesión con una combinación de email y clave válidos.

Jurić nos propone implementar este sistema. Enfocándonos únicamente en el comportamiento esperado y no toda la parafernalia necesaria su implementación. Entonces separamos el código en dos capas:

  • Interface: la parte visual.
  • Núcleo: todo lo que se realiza internamente.

Hacemos esta separación en estas dos grandes capas porque es algo bastante comprensible y lógico. Obtenemos este código en nuestro núcleo:

defmodule MySystem do
  @spec register(%{
    email: String.t(),
    password: String.t(),
    date_of_birth: Date.t() | nil,
    # ...
  }) :: # ...
  def register(params) do
end

Jurić resalta la importancia en este caso de los tipos. En este caso los tipos aportan una gran cantidad de información sobre params y por ende una mayor claridad al código. Sabemos qué esperar dentro de params.

Con este código ya sabemos cómo podemos utilizar el núcleo y podemos implementar la interfaz:

def register(conn, params) do
  schema = [
    email: {:string, required: true},
    password: {:string, required: true},
    date_of_birth: :datetime,
    # ...
  ]

  with {:ok, params} <- normalize(params, schema),
       {:ok, user} <- MySystem.register(params) do
    # respond success
  else
    {:error, reason} ->
      # respond error
  end
end

La interfaz se encarga de recolectar la información, realizar una normalización y realizar el envío de esa información al núcleo. Jurić nos señala también en la interfaz realizamos una normalización que no validación:

La validación se encarga de hacer cumplir las reglas de la lógica de negocio mientras que la normalización es solo proporcionar una estructura a unos datos sin estructura previa.

Este ejemplo es el empleado por los contextos en Phoenix Framework. Si quieres saber cómo construir una aplicación paso a paso con Phoenix Framework empleando contextos puedes echarle un vistazo al libro Phoenix Framework: Proyecto de Red Social en 7 días.

El código en la interfaz puede ser expresado también como:

def register(conn, params) do
  case MySystem.register(params) do
    {:ok, user} -> # respond success
    {:error, reason} -> # respond error
  end
end

Aunque menos líneas de código muchas veces implican una mayor claridad, en este caso hemos conseguido esto a expensas de una mayor carga en la validación y la pérdida de forma en el núcleo. Es decir hemos reducido líneas de código a expensas de la claridad global del código. El núcleo no tiene ahora la responsabilidad y la certeza de obtener los datos normalizados sino que debe ahora además flexibilizar la obtención de los mismos proporcionándoles la estructura que les falta.

Jurić puntualiza que esto no es una crítica a Phoenix Framework, para una persona que comienza con Phoenix Framework hacerlo de esta forma es perfectamente normal y recomendable. No hay nada de malo porque el propio framework se encarga de realizar la separación necesaria de los conceptos. No podemos pretender agregar en la documentación de Phoenix el material específico de Phoenix más DDD, EventSourcing, CQRS, Arquitectura Hexagonal, Microservicios, ¿quién va a entender nada con tanto?

Por lo tanto, la documentación para iniciarse con Phoenix Framework es una buena analogía de la separación de intereses.

Sin embargo cuando vamos al código del núcleo y vemos:

def register(params) do
  %User{}
  |> User.registration_changeset(params)
  |> Repo.insert()
end

Este es el único de los ejemplos donde Jurić disiente de Phoenix. Al ocultar qué se realiza en realidad al emplear la función User.registration_changeset/1 quedan más preguntas que respuestas. De hecho no se responde a nada en este código. Jurić escribiría este código mejor como:

def register(params) do
  %User{}
  |> change(%{
       email: params.email,
       password_hash: password_hash(params.password)
     })
  |> unique_constraint(:email)
  |> # otras validaciones
  |> Repo.insert()
end

Aunque Jurić señala esta organización como deseable y muchos la emplean organizando el código como los contextos donde se mantienen todas las funciones y los esquemas donde tan solo se definen los datos y nada más, hay una delgada línea a nivel organizativo y no sabría decir qué prefiero más, si tener un esquema cargado con muchas funciones referentes a consultas y modificación de datos o un esquema ligero y el peso de la lógica en los contextos. Dejada en comentarios vuestra preferencia.

Infraestructura

Jurić define como infraestructura todos los módulos o todo el código que se emplea para tener acceso a otros elementos de interacción como la base de datos, envío de emails, notificaciones, sistemas de monitorización, alertas, pasarelas de pago y un largo etcétera de elementos conectables a nuestro sistema.

En principio podemos pensar y escribir estos sistemas como capas separadas agregando más ceremonia innecesaria al proceso de emplear estos elementos como un sistema de tres capas, una arquitectura basada en inyección con puertos y adaptadores como en la arquitectura hexagonal, o arquitectura clean, o arquitectura de cebolla. Tal y como nos dice Jurić:

Lo más importante es que la lógica de negocio debe mantenerse realmente ligera, sin ninguna complejidad particular, si dividimos este tipo de código, esencialmente la parte más complicada de entender de nuestro código va a ser la arquitectura.

No obstante, también nos previene de pensar por nuestra cuenta y por contexto. Depende de los requisitos y las necesidades. Jurić nos insta a no hacer caso a otros arquitectos, líderes o incluso a él. Debemos pensar en nuestro problema específico.

Pruebas

Jurić nos indica el sentido propio de las pruebas y tal y como decía Ian Cooper en una charla sobre TDD de 2017:

Evitar probar los detalles de la implementación, probad comportamientos.

Aplicado a esta frase, debemos probar casos de uso. A diferencia de lo que se dice en otros lenguajes sobre las pruebas unitarias, Jurić nos indica la necesidad de probar comportamientos como la unidad mínima comprobable. No tiene sentido probar un módulo o detalles de la implementación de un módulo, es mejor probar el comportamiento que se pretende conseguir con la escritura de esos módulos.

Sobre los mocks y stubs, Jurić comenta no obsesionarse con esforzarse en aislar el código en exceso con el uso de estos elementos:

En la escuela clásica de TDD, cuando hablamos de aislamiento, lo que debe ser aislado o lo que queremos que se aisle la mayor parte del tiempo es la prueba en sí no lo que estamos probando. Una prueba está aislada si su ejecución no afecta la ejecución de otras pruebas de su conjunto.

Está bien hacer mocks de elementos no deterministas como la hora o números aleatorios, pero debemos intentar no hacerlo de elementos que sí son deterministas.

Uno de los mayores problemas cuando probamos detalles de la implementación es el fallo de una gran cantidad de pruebas cuando realizamos refactorizaciones que deberían mantener el mismo comportamiento e incluso el hecho de que su implicación con los detalles pueden generar falsos negativos fallando cuando el código está bien, debiendo cambiar o desactivar la prueba o incluso peor generando falsos positivos diciendo que el código está bien cuando no lo está. Jurić dice que además de desmoralizador estos problemas le hacen perder completamente la confianza en las pruebas, mientras que la confianza es el fin perseguido cuando realizamos pruebas en primer lugar.

Conclusiones

Jurić nos da mucha información de forma muy rápida. Este resumen recoge tan solo los primeros 35 minutos de su charla. Te insto a revisar su charla completa para obtener en su esencia todo el conocimiento que Jurić nos proporciona, además de todo lo que no he cubierto en este artículo.

Si quieres que haga una segunda parte agregando todo lo que falta házmelo saber en los comentarios.

¿Y tú? ¿Has probado a desarrollar de una forma más clara? ¿Cómo consigues claridad en tu código? ¡Déjanos un comentario!