Pensando en programar

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 interesa1). 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 2), 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 3) 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.


1)
o no debería, ya hablaremos más adelante de otros casos
2)
cuando es necesario, no en el ejemplito anterior
3)
menos detalle, más abstracto

Discusión

Escribe el comentario. Se permite la sintaxis wiki: