Pensando en programar

Ahora sí, estamos en disposición de plantear con un caso práctico cómo podría ser la realidad de empezar un proyecto de programación. Vamos a ver un primer caso sencillo pero suficiente. Es un proyecto que no tiene demasiada complejidad y puede ser bueno para empezar. Después, quizá, veremos otro algo más complejo. Es bueno recordar en cualquier caso que la intención no es centrarnos en detalles demasiado específicos del código, sino en comprender el planteamiento, la aproximación al problema, es decir, las decisiones que tomamos y por qué lo hacemos así.

Caso práctico: Transmisor de Morse

He dejado en un apéndice la explicación general del proyecto. Aunque es algo informal, esto podría representar un primer análisis del proyecto, una conversación con un cliente, o alguna especie de documento de requisitos. Para facilitar las cosas he preferido escribirlo más informalmente e incluir algunas ideas y detalles para que, quien quiera, pueda incluso llevar el proyecto a la realidad.

Nota para lectores entusiastas: A continuación voy a hacer un pequeño análisis y trazar un pequeño diseño de la solución. Es Buena Idea™ que intentes hacerlo tú antes de seguir leyendo. Piensa: ¿en qué consiste el problema? ¿Qué partes tiene? ¿Cómo organizaría mi solución? No hace falta que lo hagas muy detallado; recuerda que el problema es sencillo y pequeño.

Análisis del problema

Una vez leído, empezamos por analizar el problema. Al ser un problema sencillo, lo voy a hacer más o menos rápidamente. Básicamente hay una interfaz de algún tipo (no está definida en el problema cómo debe ser) que nos permite introducir un texto.

Este texto se descompone en frases, palabras y letras y entonces se traduce a símbolos Morse (guiones y puntos) y después se traduce este código a una secuencia de 1s y 0s.

Finalmente hay un dispositivo de salida (un LED originalmente pero podemos usar lo que tengamos a mano) que debemos encender y apagar según la secuencia de 1s y 0s generada.

En otro lado, un receptor recibe una secuencia de 1s y 0s, lo traduce de forma inversa a símbolos, luego letras, palabras, frases, y finalmente muestra en pantalla el texto traducido completo.

Releyendo un poco lo anterior y destacando los elementos principales se ve que el problema es relativamente pequeño. Si lo pienso un poco más, veo que he introducido un detalle que no está en el problema original. Si nos fijamos de nuevo en la explicación general de cómo se traduce a Morse y a unos y ceros, resulta que, en realidad, no hay ninguna separación o distinción particular entre frases. Este es un concepto que he extrapolado yo accidentalmente pensando en que en el lenguaje normal nosotros sí utilizamos una separación entre frases (el punto ortográfico, generalmente). Pero la codificación Morse no se preocupa de esto. Simplemente ese punto ortográfico es una letra más. Así que, revisamos el análisis y eliminamos la preocupación por las frases.

Solución planteada

Podemos imaginar, si nuestra intención es hacer un transmisor -o una pareja de transmisores- completo, que de alguna manera los dos sistemas no son tan independientes. Si queremos un transmisor completo necesitamos ambas partes y necesitaremos integrarlas de alguna forma que resulte práctica y cómoda. Esto es totalmente correcto. Sin embargo tengo dos motivos para hacerlo por separado. Uno es que, al ser un ejercicio, no lo resolveré completo y me centraré solo en una parte y esto lo hace más sencillo. El segundo, más interesante, es un consejo general: Siempre suele ser más fácil integrar dos sistemas que hayamos creado como cosas separadas, que tomar un único sistema que hayamos hecho completo y después dividirlo.

Insisto en que este es un problema pequeño. No hay una gran complejidad de lógica, y además el número de partes involucradas es bastante escaso. Por eso me voy a limitar a hacer un par de bocetos generales con poco detalle.

Como primera idea, veo claro que hay en principio dos sistemas o preocupaciones totalmente independientes. Si vemos el análisis, claramente pensamos en que en un lado hay un emisor y en el otro lado hay un receptor. Y que estos funcionan de manera separada. Así que me planteo que cada uno sea su propio sistema e independiente.

 Boceto básico

Para el emisor dibujo un esquema simple como el que se ve. Es un esquema muy simple, sí, de poco detalle. Solo presenta que tenemos 3 partes: la entrada del texto, el sub-sistema de traducción, y la salida de la secuencia al LED. ¿Dónde está reflejado todo lo que resaltaba en el análisis? Como digo este es un esquema con poco detalle. Algunas cosas sí están reflejadas. El concepto de texto es lo que la entrada pasa al traductor, y el concepto de secuencia de 1s y 0s es lo que el traductor pasa a la salida. El “traductor” surge de que lo que destaca en el análisis como verbo o acción principal es justo eso: traducir. Y es ahí donde pueden intervenir el resto de conceptos que hemos visto. palabras, símbolos, etc, esto únicamente preocupa al traductor. Ni la entrada ni la salida necesitan preocuparse por ellos. En cierto modo, aunque en este caso sea un dominio muy pequeño (porque, insisto, pequeño es el problema), este es realmente el dominio, la traducción de palabras y letras a símbolos y a secuencias de 1s y 0s.

Con esta idea, hago un segundo boceto centrado en ese dominio. En realidad, en este caso, más que otra cosa, dado que el problema es tan pequeño, es un flujo general del proceso que necesito seguir para la traducción. Dividir el texto en palabras, traducirlas y unirlas con 7 ceros. Para traducir cada palabra tendré que: dividirla en letras, traducirlas y unirlas con 3 ceros. Para traducir cada letra tendré que: traducirla a símbolos y dividirla en símbolos, traducir cada símbolo y unirlos con 1 cero.

Boceto de la traducción

Al plantear esta solución me doy cuenta de algo: Originalmente el problema parecía plantear que hay dos traducciones involucradas: Una primera a símbolos Morse y otra segunda a 1s y 0s. Conceptualmente es así y hacemos esas dos traducciones. En la práctica, nada necesita la representación intermedia en Morse (puntos y guiones) y sólo necesitamos la representación final. Si el sistema fuera otro en el que estuviera justificado mantener la representación de puntos y guiones, entonces podríamos plantearnos hacer las dos traducciones por separado. Sin embargo, dado que no tenemos esa necesidad, hacer las dos traducciones a la vez nos simplifica mucho el diseño de la solución. Y las soluciones simples nos gustan muchísimo más que las soluciones innecesariamente complicadas :)

Se ven unos ciertos "niveles" o "capas"

Código

Hemos hecho nuestro análisis y conocemos bien el problema. Hemos planteado un diseño general de la solución identificando el dominio del problema y planteándolo en más detalle, y hemos incluso encontrado algún detalle interesante que nos ha hecho comprender que la representación en puntos y guiones es sólo conceptual y que no la necesitamos como algo que vayamos a utilizar. Parece que tenemos todo suficientemente estudiado y listo. Empecemos a escribir algo de código.

Como explicaba en el capítulo anterior yo tiendo a empezar a desarrollar por el dominio de la solución. La parte más esencial del problema a resolver. Podemos utilizar diferentes herramientas o técnicas para poder ir verificando o probando lo que hacemos. Una que me gusta es la de utilizar un REPL o una consola del lenguaje que usemos. No todos los lenguajes tienen, pero cuando sí, es bastante útil porque nos permite ejecutar directamente lo que queramos, llamar a una función o lo que necesitemos. La respuesta es inmediata y podemos probar y comprobar cosas muy rápido. Como alternativa, la idea de escribir algún test también puede ayudarnos.

Yo, en estas fases exploratorias, suelo usar un REPL o una consola, o si, como en este caso, es sencillo, entonces empiezo a escribirlo sin más. Así que empiezo. Miro lo que había pintado y veo dos cosas. La primera es que el proceso va a ser una serie de operaciones a diferentes niveles. Primero trato con el texto completo, lo divido en palabras, hago la traducción de cada palabra (no me preocupo ahora por cómo) y termino juntando las palabras con 7 ceros. ¿Cómo traduzco una palabra? Pues lo divido en letras, traduzco cada letra (no me preocupo por ahora por cómo) y termino juntando las letras con 3 ceros. ¿Cómo traduzco una letra? Pues divido en símbolos, traduzco cada símbolo y termino juntando con ceros. La segunda cosa que veo es que las operaciones son todas muy parecidas, si las miramos así sin fijarnos en los detalles. Este segundo aspecto lo usaré más tarde, pero es bueno que lo haya notado ya.

De modo que empiezo a escribir el código y pienso que traducir va a ser una función. En otros casos podría pensar en un objeto, una clase, u otra cosa pero parece bastante claro que encaja como una función. Es un verbo, va a recibir unos datos y me va a devolver otros. Claramente es una función.

funcion traducir(texto) {
    // ...
    devolver secuencia;
}

Ahora uso una técnica que refleja directamente lo que un poco más arriba entre paréntesis. Decía: “…traduzco cada palabra (no me preocupo ahora cómo)…”. Es lo mismo de lo que hablaba cuando hablaba de la descomposición despreocupada de la solución. Aquí es muy similar. Algunos lo llaman wishful thinking. En escritura es el método de la bola de nieve. La idea es, yo me mantengo en el nivel de detalle en el que estoy, escribo mi código como si las cosas que quiero usar estuvieran ya hechas. Después, veo qué cosas no están realmente hechas y entonces bajo a ese nivel de detalle. Con un ejemplo se ve mejor, así que escribo:

funcion traducir(texto) {
    palabras = partir( texto, " " ); // divido el texto en palabras
    porCada( palabra de palabras ) {
        palabrasProcesadas.guardar( procesarPalabra(palabra) );
    }
    secuencia = juntar( palabrasProcesadas, "0000000" );
    devolver secuencia;
}

Aunque creo que se entiende el pseudo-codigo anterior, voy a pasar a usar un lenguaje de verdad 1) para evitar detalles innecesarios.

function textToSequence(text) {
    let result = "";
    let words = wordsFromSentence(text);
    for (let i = 0; i < words.length; i++) {
        result += wordToSequence(words[i]);
        result += "0000000";
    }
    return result;
}

Esto podría ser una aproximación un tanto básica, pero perfectamente válida. Funciona y se comprende más o menos fácilmente. En realidad, prefiero yo prefiero escribir esto otro:

function textToSequence(text) {
    let words = wordsFromSentence( text );
    let processedWords = words.map( wordToSequence );
    let sequence = join( processedWords, "0000000" );
    return sequence;
}

Bueno, seguramente ni siquiera escribiría eso exactamente, pero es una aproximación suficiente que aún conserva la estructura del diagrama que he enseñado más arriba.

Como decía, mi forma de plantearlo es definir el proceso en el nivel de detalle en el que estoy y como se ve sigue muy claramente ese nivel. Ahora me encuentro con que hay 3 cosas que no tengo: las funciones wordsFromSentence, wordToSequence y join. Pero “si tuviera esas tres funciones y funcionaran como espero que funcionarán” entonces la función textToSequence estará hecha.

Como puede entenderse, dos de esas operaciones son muy simples y el propio lenguaje ya me las proporciona, así que quedaría únicamente wordToSequence:

function textToSequence(text) {
    let words = wordsFromSentence( text );
    let processedWords = words.map( wordToSequence );
    let sequence = join( processedWords, "0000000" );
    return sequence;
}

Este, implementar wordToSequence, es el siguiente nivel de detalle. Ahora no me preocupo de textos completos sino de una palabra nada más. Con el mismo proceso anterior escribiría algo como:

function wordToSequence(word) {
    let letters = lettersFromWord( word );
    let processedLetters = letters.map( letterToSequence );
    let sequence = join( processedLetters, "000" );
    return sequence;
}

De nuevo, no tengo aún letterToSequence pero cuando la tenga, este nivel estará terminado. Siguiendo este camino hasta el final, podría llegar hasta algo parecido a esto:

function textToSequence(text) {
    let words = wordsFromSentence( text );
    let processedWords = words.map( wordToSequence );
    let sequence = join( processedWords, "0000000" );
    return sequence;
}
function wordToSequence(word) {
    let letters = lettersFromWord( word );
    let processedLetters = letters.map( letterToSequence );
    let sequence = join( processedLetters, "000" );
    return sequence;
}
function letterToSequence(letter) {
    let symbols = letterToMorse( letter );
    let processedSymbols = symbols.map( symbolToBin );
    let sequence = join( processedSymbols, "0" );
    return sequence;
}
function symbolToBin(symbol) {
    if (symbol === '.') return "1";
    else return "111";
}

Todavía faltan cosas ahí, claro. Más aún, hay detalles que no están limpios. No importa. Esta es una fase exploratoria, recordemos. Nos interesa sobre todo verificar que la solución que habíamos pensado realmente es realizable y descubrir posibles problemas o detalles que no hubiéramos tenido en cuenta. Hasta aquí, y a falta de terminar lo que sigue, tiene buena pinta. No parece haber nada inesperado y lo único que nos queda son pequeños detalles. Lo único más o menos relevante que queda es letterToMorse, pero dada la tabla que tenemos en el apéndice con el enunciado, se trata de una simple búsqueda:

function letterToMorse(letter) {
    return dictionary[letter].split("");
}

Perfilando mejor el código

Nota: Quiero insistir en que el proyecto, aunque claramente real, es un proyecto decididamente pequeño. Por tanto no vamos a necesitar una gran sofisticación o una solución muy elaborada para obtener un buen resultado. Además de esto, por supuesto que la solución no está completa.2)

Si miramos un poco nuestro código vemos que hay algunas cosas que podríamos mejorar o que podríamos tener en cuenta de alguna forma. Por ejemplo, es muy evidente que las tres funciones *ToSequence son estructuralmente idénticas. Es decir, cambia el tipo de datos que están manejando o el separador que utilizan o algunos detalles menores, pero su estructura general es la misma en los tres casos.

funcion algoToSequence(algo) {
    trozos = trocear( algo );
    trozosProcesados = trozos.map( procesarTrozo );
    secuencia = juntar (trozosProcesados, SEPARADOR);
    return secuencia;
}

Podríamos fácilmente pensar en hacer una operación más genérica que podamos aplicar en los tres casos reduciendo la repetición.

Aquí es donde entra en juego el tema de la economía del código y del principio fundamental de la programación. Podríamos mejorar la solución que tenemos, por supuesto que sí. Y seguramente muchas de esas mejoras son interesantes. Ahora bien, debemos plantearnos: Estos cambios, este refinamiento ¿compensa?. ¿Nos aporta un beneficio mayor que el coste que supone?

Obviamente la respuesta depende de cada caso, como ya sabemos. Puede que solo estemos haciendo esto hoy y no vayamos a volver a tocar este código nunca. El rendimiento no parece ser un problema. No vamos a querer añadir más opciones ni funcionalidades. Podríamos dejarlo así y estaría bien. El código es claro, es sencillo, y funciona.

Por otro lado, si vamos a querer llevar a cabo todo el proyecto completo, a lo mejor incluso montando el hardware para “jugar”, quizá queramos un código mejor. Puede que vayamos a compartirlo con un amigo para que lo use él también. Puede que lo queramos incluir en un proyecto más grande en el que traducimos entre diferentes tipos de códigos. Puede que sea conveniente mejorarlo.

Me encantaría que tu caso real fuera que vas a montar en casa un sistema de comunicación visual o sonora con máquinas con aspecto steampunk y estrafalarias tareas que solicitarse unas a otras. Y desde luego, si estás encerrado en una mazmorra y lo único que tienes a tu disposición es un ordenador y un circuito con una bombilla, no lo dudes: ponte a programar ya mismo y úsalo para pedir ayuda.

Mi caso real es el de un ejemplo. Es el de intentar enseñarte a pensar en cómo programar mejor. Con esto como objetivo, parece muy claro, que sí, que no puedo estar satisfecho con el código tal como está y que debo seguir avanzando para explicar cómo escribir mejor código. El beneficio -que tú aprendas estas cosas- supera el coste -que yo tenga que escribirlas-.

Por eso no, el código no está, ni mucho menos, terminado. Veremos, en próximos capítulos cómo continuar con este código en varios aspectos y por diferentes caminos. Mientras tanto, si quieres puedes ir avanzando tú mismo con tu proyecto personal o puedes hacer ejercicio con otro caso como el juego Mastermind -que tiene una complejidad algo mayor que esto, pero es muy asequible3)-.


1)
Bueno… JavaScript
2)
Se puede ver la solución completa en los apéndices y extras.
3)
Más adelante muy seguramente hagamos también alguna parte de este ejercicio

Discusión

Escribe el comentario. Se permite la sintaxis wiki: