La suma de las partes

Como hemos visto una herramienta fundamental para poder abordar los problemas y la construcción de las soluciones es la descomposición. Separamos el todo en partes más pequeñas, más fáciles de "atacar", y las desarrollamos una a una. Pero claro, esto nos plantea una nueva dificultad: De alguna forma luego tendremos que hacer que esas partes encajen y trabajen unas con otras para producir la solución completa.

La Suma de las Partes

Como es habitual en la programación, y en otras muchas cosas, no hay una única forma de hacer las cosas. Sin embargo, sí hay una serie de ideas que podemos mantener siempre como referencia para aplicar en nuestra solución.

Uno de los principios que yo más repito es eso de "Junta lo que va junto. Separa lo que va separado." pero soltar frases chulas es muy sencillo. ¿Cómo se aplica luego en la realidad? De hecho, la parte de separar y juntar es "relativamente" sencilla. No siempre, claro, pero n general es algo que podemos ejercitar e ir mejorando con la práctica sin excesiva dificultad. También, como veíamos, hay técnicas que ayudan bastante, como lo de diseñar estableciendo "niveles de detalle", por ejemplo.

Pero también debemos pensar en algunos detalles que van un poco más allá de cada parte por separado.

Interfaces, Bordes, Fronteras

Cuando creamos un componente, una función, un sub-sistema... una pieza de nuestro programa, es bueno plantearnos dos puntos de vista o dos partes de esa pieza: "lo de dentro" y "lo de fuera".

De lo que se trata es de pensar en dos cosas:

  • ¿Qué es de lo que se ocupa esta pieza? Es decir, básicamente, qué cosas debo juntar aquí o qué es lo que va dentro de esta pieza.
  • ¿Cómo se relacionará esta pieza con el resto? O en otras palabras, qué es lo que se verá de esta pieza desde fuera.

En términos más técnicos, estamos hablando de lo que es la encapsulación y el diseño de interfaces. Ojo, que cuando digo "diseño de interfaces" no me refiero a interfaces de usuario. Estos son solo un tipo de interfaz, pero de forma más general, cualquier parte o pieza tiene su propia interfaz hacia el resto del programa.

Por ejemplo, el interfaz de una función es la definición que nos dice cómo se llama la propia función, qué parámetros debemos pasar al llamarla y qué resultados nos va a devolver.

Imaginemos una función que cuenta cuantos números pares hay en un array, cuántos impares, cuántos positivos y cuántos negativos:

function analize(collection) {

  return collection.reduce(function(k,v) {
      if (v > 0) k['positive']++;
      else if (v < 0) k['negative']++;
      
      if (v%2) k['odd']++;
      else k['even']++;
      
      return k;
  }, { odd: 0, even: 0, positive: 0, negative: 0 });

}

Cuando nosotros estemos llamando a esta función en un caso como analize([1,2,3,-4,3,-7,4,-7,3]);, realmente todo lo que ocurre dentro de ella, lo que llamamos el cuerpo de la función, no nos interesa. Lo que nos interesa es lo que vemos desde fuera. Primero que la tenemos que llamar con ese nombre que tiene y que tenemos que pasar un array de números. Segundo que nos va a devolver un objeto con los resultados. Estas dos cosas son las que conforman su interfaz, lo que el resto del mundo, el resto del código, puede ver de ella.

function analize(collection) {                // <- Esto sí

  // Blablabla                                // <- Esto no

  return { odd, even, positive, negative };   // <- Esto sí

}

Obviamente una única función es una pieza muy pequeña y por tanto su interfaz es igualmente pequeño. Pero todas las piezas que queramos considerar en un sistema, tienen esta misma característica. Una clase, un módulo, una librería, un componente, un programa completo, incluso el propio ordenador que estemos usando tiene una parte que manejamos y una parte interior que no vemos.

Protocolos

Cuando hablamos de interfaces, de fronteras o bordes entre diferentes piezas, estamos hablando también de comunicación. "Cómo unas partes encajan con otras" es lo mismo que decir "cómo se comunican unas piezas con otras". Esa otra perspectiva generalmente suele tener reflejo en lo que llamamos protocolos. Esto es, en la definición de los mensajes que pasamos de una pieza a otra.

Un protocolo no es más que definición de la forma que tienen estos mensajes. Por ejemplo, en el caso anterior, establecemos que el resultado de la función analize es un objeto con una forma como { odds, even, positive, negative }. Esto, el hecho de que nuestro mensaje de resultado tiene esa forma concreta, es nuestro protocolo.

La definición de protocolos suele ser más detallada, incluyendo formatos de los datos incluidos, valores que pueden tomar, etc.

Características de un buen interfaz

La importancia del diseño de interfaces reside en que, por una parte, va a ser lo que nos permita encajar mejor o peor unas piezas con otras, y, por otra, lo que defina claramente la solidez interna de cada parte. Es, por tanto, una preocupación clave según vamos subiendo desde piezas más pequeñas hasta niveles de detalle más alejados y construyendo así nuestro programa completo a base de unir todas las piezas.

Hay varios factores y aspectos que debemos tener en cuenta si queremos conseguir un buen interfaz que nos facilite la tarea de construir luego sistemas mayores con esa pieza.

Simplicidad

Esta es una característica que siempre buscamos en todo lo que hacemos, pero es especialmente interesante en la definición de interfaces, porque cuanto más complejo sea el interfaz, cuantos más elementos involucre, más difícil y costoso se hace utilizarlo.

Opacidad

Un buen interfaz no deja que veamos el interior. Es decir, un buen interfaz hace que realmente no nos tengamos que preocupar de ninguna forma por cómo funcionan las cosas por dentro. Cuanto más opaco sea el interfaz, más sólidamente estará definida nuestra pieza.

Semántica

Un buen interfaz no solo transmite claramente su intención con un significado bien definido, sino que debe estar explícitamente pensado para separar esos niveles de detalle con los que tanto doy la lata. La idea es que un interfaz bien definido expresará un significado en un nivel de detalle superior al de la lógica que contiene la pieza en su interior.

Diseño de interfaces y sistemas

Una de mis técnicas preferidas para el diseño de interfaces es similar a la que ya he comentado alguna vez de la descomposición despreocupada. Más en general esta técnica se suele llamar wishful thinking o wishful programming.

Insisto en la idea. Se trata de escribir cómo nos gustaría que nuestro componente, nuestra pieza, se use cuando ya la tengamos hecha, aunque por ahora no tengamos nada en absoluto. Es decir, antes de empezar siquiera a desarrollar un cierto elemento, nos planteamos la pregunta "¿Cómo quiero que luego se use?".

Imaginemos que necesitamos construir un sistema de control de semáforos. Esto tiene una complejidad muy alta. Hay que sincronizar semáforos unos con otros y mil cosas más. Pero centrémonos en un semáforo en particular nada más. Obviamente necesitamos un semáforo para tener un sistema de control de semáforos. Hacemos un análisis de cómo funciona un semáforo, qué cosas necesitamos, etc. Igualmente diseñamos una solución o un plan para implementar la solución. Tras pensar todas estas cosas, hemos visto que el semáforo necesita mantener un cierto estado (rojo, amarillo, verde) y que tiene que tener una forma en que podamos decirle que cambie de estado y otra para que nos diga que ha cambiado de estado.

Y podemos pensar en cómo funcionará todo esto por dentro, cómo guardaremos ese estado, cómo implementaremos las reglas de, por ejemplo, pasar de verde a amarillo, y de amarillo a rojo después de un tiempo X y... bueno, todo lo que es el funcionamiento interno del semáforo. Pero en lugar de preocuparnos por eso todavía, lo primero que hacemos es esto otro: Pensar en cómo vamos a querer luego usar ese componente Semáforo cuando lo estemos integrando en el resto del programa. Podríamos pensar algo así como...

// Cuando necesite un semáforo, podría hacer algo como esto...
let s = new Semaphore();

// Y luego cuando quiera pedir que abra o cierre, quiero poder hacerlo así...
s.stop(); // o s.go();

// Y cuando el semáforo cambie su estado, quiero que me pueda avisar, por ejemplo, así...
s.onChange(myFunction);

Esto es solo un caso simple. Podemos añadir más detalle o pensar en que nuestro semáforo funcione de otra forma. Lo importante es que realmente no estamos diciendo nada de cómo será el semáforo por dentro, de cómo será capaz de hacer esas cosas o qué implicará cada acción. Solo estamos estableciendo qué cosas nos van a importar y afectar desde fuera del semáforo.

Contratos

En el fondo, de lo que se trata cuando definimos un interfaz, un protocolo, un formato, es de establecer un contrato. Es decir, estamos estableciendo el modo en que dos -o más- partes se van a comunicar, lo que pueden/deben hacer y lo que no.

El uso de contratos es un concepto bastante extendido -en diversas formas- y es una ayuda fundamental para manejar la complejidad de los sistemas. Nos permite fijar unos puntos concretos en que se deben cumplir determinadas condiciones o restricciones. Esto facilita mucho las cosas a la hora de razonar sobre nuestros sistemas y, especialmente, al tratar de encontrar el origen o localización de algún problema: Podemos mirar qué parte no está cumpliendo su compromiso con el contrato y así localizar qué parte es la que no se está comportando como debe.

Los contratos no solo son importantes cuando trabajamos con más personas o con más equipos, también nos ayudan a nosotros, como programadores, como puntos que marcan esas fronteras o separaciones entre las diversas partes.

Caso práctico

Recordando el proyecto del Transmisor de código Morse, apenas nos centramos en una parte concreta del sistema, la traducción de texto a secuencia de unos y ceros. Esto nos llevó a que definíamos un módulo en el que se hacía todo ese proceso, con diferentes funciones en su interior, pero que exponía una única función. Esta era:

funcion traducir(texto) {

  // ...
  devolver secuencia;

}

Este es el interfaz del módulo. Es la parte que es visible para el resto del sistema y con la que interactuarán. Es un buen ejemplo de interfaz. Es simple. Presenta un área mucho menor que el contenido del módulo (es decir, exponemos solo lo necesario). Utiliza el lenguaje del nivel superior; cuando usemos nuestro módulo no estaremos hablando de palabras, separadores, puntos y guiones... hablamos de un texto y de traducirlo a otra codificación. Podríamos, quizá, elegir como nombre justo eso codificar, sí, pero con la información que tenemos, traducir es igualmente buen nombre.

Otras partes del sistema podrían ser la interfaz de usuario, UI, para escribir mensajes, el controlador del circuito del LED... si nos decidimos por hacerlo bidireccional también tendremos el sensor de luz, la traducción inversa, otra parte de la UI que presente los mensajes recibidos.

Entre todas estas partes podemos ver, por ejemplo, que la UI gestionará las interacciones con el mismo y que si en un momento dado tenemos que presentar mensajes (recibidos o mensajes de error) seguramente tendremos que exponer alguna función como presentarMensaje o avisarError. Posiblemente serán dos, porque tienen significados distintos. Podemos imaginar también que si tenemos el sensor para recibir mensajes, este deberá indicar de alguna forma que, efectivamente, ha recibido un mensaje nuevo para procesar. Podríamos hacerlo de diferentes modos, pero se me ocurre que uno de esos modos podría ser que el sensor ofrezca una forma de suscribirnos a un evento de "nuevo mensaje recibido" y que cuando esto ocurra, se limite a disparar el evento.

También se me ocurre que si hacemos la traducción inversa será bastante razonable -porque compartirán partes del código y de los conceptos- que forme parte del mismo módulo que la traducción directa. Evidentemente tendremos que ampliar nuestra interfaz con una nueva función expuesta. Con esto en mente, seguramente entonces tendría sentido nombrar estas funciones de otro modo:

funcion codificar(texto) {

  // ...
  devolver secuencia;

}
funcion decodificar(secuencia) {

  // ...
  devolver texto;

}

Este interfaz es más consistente que si mantuviéramos el nombre original de traducir. Ahora estamos gestionando una comunicación bidireccional y por tanto el vocabulario que manejaremos a ese nivel tendrá que reflejar esa dirección. traducir no indica ninguna dirección. codificar y decodificar son nombres simétricos, lo que los hace complementarios, y esto nos da esa consistencia.

Si el sistema fuera más complejo, con diferentes modos de comunicación, diferentes códigos, etc, podríamos entonces llegar a necesitar definir algunos formatos y protocolos. Podríamos, por ejemplo, envolver cada mensaje/dato en algún tipo de objeto con información adicional que exprese si es texto plano, si es una secuencia de unos y ceros, si es un formato binario, etc. Esto lo podríamos hacer a base de simplemente envolver el dato en un contenedor genérico, o podríamos crear diferentes tipos de datos o una jerarquía de clases o/y objetos que representen cada uno de los formatos... Para nuestro sencillo proyecto esto no es necesario.