domingo, 27 de febrero de 2022

Los Cinco Verbos de la Programación

En el principio era la nada.

Luego alguien va a decir "Es que no son verbos de lo que se habla aquí". Y bueno, un verbo es semánticamente una acción, y aunque pueda tener muchos sinónimos sigue siendo la misma acción, por eso se usa el término "verbo"; una vez hecha esa aclaración, continuamos.

Después de mucho análisis y de buscar patrones comunes entre una pléyade de lenguajes de programación de nivel medio y alto, podemos notar que todos suelen estar fundamentados en cinco (quizás seis) acciones bien definidas, esos "verbos" suelen estar presentes en la gran mayoría de los lenguajes de programación, tanto estructurada, orientada a objetos y, aunque más limitado, funcional. Y son los siguientes:

  1. Declaración.
  2. Asignación.
  3. Decisión.
  4. Iteración.
  5. Invocación (y quizás 6... redirección).

A través de esas seis acciones se pueden construir todos los discursos posibles en cualquier programa. Todavía está en duda si tales lenguajes tienen partículas propias de los lenguajes naturales como sustantivos o adjetivos, pero es claro que los cinco verbos están presentes.

Declaración.

¿Para qué sirve "declarar" algo en un programma? Pues para establecer el contexto en el cual se pueda desarrollar el mismo. A través de declaraciones volvemos explícito y accesible a nuestro contexto; no solamente las variables son declaradas, también se declaran funciones internas y externas y, en general, cualquier referencia que vaya a usar el programa.

Hay bastantes tipos de declaraciones que se pueden realizar, las más simples son las "constantes", que no son mas que sinónimos que utilizaremos a lo largo del programa para que sea más fácil de leer. No todos los lenguajes soportan o manejan constantes como tal, pero la mayoría de los programadores las usamos; las constantes nos sirven para establecer parámetros internos de funcionamiento, por ejemplo, si quiero que un número de operaciones siempre pausen 7000 milisegundos, puedo declarar una constante ESPERA cuyo valor sea 7000 y en lugar de tener un montón de números 7000 por todo el código, tendré leyendas que marquen ESPERA, y si necesito cambiar ese valor global, basta que modifique la constante. Muchos programas viejos usan mucho las constantes para intentar mantener una legibilidad del código.

Otro uso de las constantes es para establecer valores o banderas a nivel sistema operativo o con la interacción con otros programas. Por ejemplo, el estándar de POSIX establece una serie de banderas o indicadores de operación a través de constantes predefinidas. PHP hace lo mismo para algunos parámetros y Java igualmente tiene sus campos static final para establecer ayudas nemotécnicas para parámetros de operación de una clase.

Por último, algunos lenguajes manejan mejor su memoria si los apuntadores a objetos son constantes, y por tanto, Javascript suele usar muchas constantes para apuntar a funciones y arreglos.

Después de las constantes, tenemos aquellos depósitos de información a los cuales sí les podemos cambiar el contenido. A esas se les conoce como variables y son extensamente usadas, tanto para almacenar valores con los que vamos a trabajar, depositar el resultado de la ejecución de funciones, pero también como apuntadores a secciones de memoria donde guardamos valores o código ejecutable. Es necesario declarar las variables y aunque haya lenguajes como PHP, Python o javascript que nos permitan omitir la declaración (en realidad se hace de manera tácita, y una variable no puede ser leída si antes en el código no se le asignó un valor) sigue siendo una buena idea saber dónde se está declarando la variable.

Las variables suelen estar constreñidas a un contexto de ejecución o "ámbito", y se supone que una variable que sale de ámbito es limpiada por el recolector de basura cuando ya no es accesible por ningún contexto. En lenguajes como C++ hay que tener cuidado con ésto, por que se debe hacer manualmente, sin embargo, Javascript ni siquiera te permite acceder a ese nivel.

Las variables, y según el lenguaje, podrán tener un tipo estricto o ser de tipo dinámico. ¿Qué es eso del tipo? Pues nada más que una "declaración de intenciones" de lo que pretendemos hacer con ese espacio de memoria. Los lenguajes de tipo estricto requieren que yo marque en la declaración si pienso almacenar números, caracteres, referencias a objetos, apuntadores, etc. y esto le ayuda al compilador a optimizar el uso de la memoria a la hora de ejecutar el programa; los lenguajes de tipo estricto suelen tener algunas complicaciones para transformar de un tipo a otro, e incluso tienen maravillas como la estructura "union" en C, que permite acceder al mismo espacio de memoria desde dos tipos distintos. Pero siempre es importante recordar: en la memoria se guardan ceros y unos agrupados en números de 8, no importa qué declares en tu lenguaje.

Por otro lado, los lenguajes de tipo dinámico no requieren que se indique claramente qué tipo de valor será almacenado, y cuando se requiere un proceso estricto de tipo, se debe hacer una conversión explícita para que el número dos, no sea interpretado como el caracter 2. (el famoso console.log("2" + 2); //"22"). Para los programadores nóveles suelen resultar más simples de entender los lenguajes de tipo dinámico, por que no suelen estar acostumbrados a saber que la información tienen tipos, sin embargo, tener consciencia de la existencia de los tipos de datos nos ayudará a escribir programas más eficientes y rápidos al evitar conversiones inútiles.

Por último, también se pueden declarar funciones, que en el fondo no es otra cosa que un apuntador a una sección de memoria donde existe un trozo de código ejecutable. Aquí las cosas se tuercen mucho: Los lenguajes tradicionales como C, BASIC, etc. suelen tener una declaración totalmente heterogénea en sintaxis de sus funciones. Incluso en C y C++, existen los archivos de encabezado (headers o .h) donde se declaran todas las funciones que se usarán en un determinado archivo de código. En Javascript en cambio esta asignación es idéntica a la declaración de una variable o constante más y se hace dinámicamente a través de apuntadores a funciones, que pueden ser anónimas. Y aunque ambos métodos de declaración de funciones parecerían totalmente ajenos, en el fondo son mecanismos idénticos.

Y todas las declaraciones cumplen con el objetivo mismo de generar el contexto (el "patio de juegos") en el cual se ejecutará nuestro programa. Si no lo declaramos en algún punto, simplemente no existirá durante la ejecución de nuestro programa.

"Pero, hay muchas cosas que yo nunca declaré y que sí puedo usar" podrá esgrimir un programador novato como argumento para negar lo anterior, y bueno usualmente los lenguajes de programación incluyen algunas funciones e instrucciones integradas dentro del mismo lenguaje. Y también se suelen incluir unas cositas maravillosas que se llaman bibliotecas (o mal traducido como "librerías"...) que nos dan acceso a funciones, constantes y a veces variables que podremos usar. Sin embargo, que no lo hayamos hecho nosotros, no quiere decir que no lo haya hecho alguien más. Al final TODO ha sido declarado en algún punto del programa. A veces, antes del inicio de la sección que escribimos nosotros.

Asignación.

A fin de poder hacer uso de los depósitos de memoria declaramos y que están representados en las variables, necesitamos un verbo para "llenar" esos almacenes. Ese verbo es la Asignación.

Como tal, es la instrucción de trasladar un trozo de información (o el resultado de una invocación) a una sección previamente declarada de la memoria. Sin asignación, simplemente sería imposible poner información útil en las variables.

Lo importante en este caso es interpretar correctamente qué estamos asignando y a dónde: podemos asignar valores estáticos o constantes (a veces se le llama en el argot "hardcoding"), por ejemplo: una cadena de texto "Hola, mundo" que casi en todos los lenguajes estará entrecomillada, marcando que su procesamiento será el de una cadena literal, es decir, que no intentará ver si las palabras "hola" y "mundo", y la coma, representan nombres de variables o una operación a realizar. Sin embargo, si escribimos "hola() + mundo()", estamos dando la indicación que ambas palabras son funciones, y se realizará una suma del resultado de la invocación de ambas funciones. Y si escribimos "hola + mundo", el intérprete intentará resolver "hola" y "mundo" como nombres de variables y luego intentará sumar sus valores. Estos tres ejemplos sirven para marcar la importancia de leer correctamente la instrucción que le estamos enviando a la máquina.

Yendo un poco más allá: los compiladores e intérpretes tienden a asumir que todo lo que está escrito es una palabra que pueden volver instrucciones a la máquina, a no ser que le digamos explícitamente que lo que estamos escribiendo es un valor constante que nosotros decidimos, y la forma más rápida es usar comillas. Los números siempre se interpretan como valores constantes, incluso algunos programas prohíben el uso de un número como primer caracter del nombre de una variable.

Hay lenguajes que permiten la asignación no solamente de valores, sino también de segmentos de código ejecutable. Cabe señalar que en ocasiones esta posibilidad puede abrir la puerta a riesgos de seguridad, pero también agrega flexibilidad al uso del lenguaje.

Toda asignación en los lenguajes que basan su sintaxis en C es representada por un signo "=" simple entre el nombre de una variable y el valor que se le va a asignar. Otros lenguajes pueden usar ":=" o palabras claves como "let", "set" u otras para denotar que el signo igual que va a seguir es una asignación, y no una comparación u operador lógico.

Decisión.

Los programas, igual que los discursos, tienen una secuencia de ejecución (tema abordado aquí). La decisión permite a los programas pueden cambiar el camino que siguen al ejecutarse.

Las decisiones suelen evaluar una proposición lógica (básicamente, invocan una función nativa), y tomar acciones según sea verdadera o no. ¿Suena fácil? de hecho sí pero muchos programadores novatos suelen tener problemas severos al entender este verbo. Por eso vamos a intentar analizarlo con más cuidado.

Primero que nada existen dos formas de tomar una decisión: entre dos caminos definidos o una selección de casos.

La primera es la más simple y se suele traducir coloquialmente como la pregunta "Si", es decir "Si hay leche, cena cereal"; este constructo además acepta una acción en caso de negativa, quedando así: "Si hay leche, cena cereal, si no, cena una fruta". También puede expandirse y encadenarse con otras decisiones binarias, y se puede volver: "Si hay leche, cena cereal, si no, si hay queso, prepara una quesadilla y cena una quesadilla si no, si tienes dinero sal a cenar tacos, si no quéjate en tuiter"; en el ejemplo anterior hay dos cosas a notar: se puede considerar que no tiene sentido agregar la instrucción "cena una quesadilla", pero recordemos que una computadora prepararía la quesadilla y la dejaría en el plato, si no le das la instrucción de que la use como cena; lo segundo es que el encadenamiento de decisiones se puede parecer mucho a la segunda forma de tomar decisiones: el caso específico.

La sentencia de caso específico es en mucho similar a la estructura de los "si" pero es sintácticamente más clara a los ojos de los programadores, además, tiene la diferencia de que funciona preferentemente sobre un valor constante y nos sirve para establecer tabuladores, más o menos como sigue: "Según sea la edad del usuario, en caso de que sea menor que 18, entonces trátalo como menor de edad; en caso de que sea menor que 25, entonces trátalo como joven; en caso de que sea menor que 60, trátalo como adulto; si no, trátalo como anciano". Aquí todas las decisiones se toman en base al valor dado de la edad del usuario, y si bien lograríamos el mismo efecto con un encadenamiento de preguntas "si", es más fácil de leer en el código la segunda opción. Es como decir "mira, según sea el caso"

Además, cuando se usa un "si", suele ser para tomar grandes derroteros en el flujo del programa, mientras que los "selectores" se usan más para asignación de valores en corto.

Como sea, ambos nos ayudan a hacer que nuestros programas reaccionen y adapten sus caminos según sea necesario para nuestros objetivos, evitando estar condenados siempre a la caída de piedra.

Antes de terminar con este verbo, es meritorio mencionar algo de suma relevancia el "cortocircuitado de decisiones". Comprender el funcionamiento de este mecanismo puede ser muy útil para mejorar en mucho la redacción de nuestros programas. Se le llama decisión "cortocircuitada" al mecanismo de evaluación que tienen algunos lenguajes de programación que determinan la secuencia en la que son evaluadas las expresiones de una sentencia "si".

Si vamos a tomar una decisión en base a dos expresiones, como la expresión "Si hay cereal y hay leche..." que evalúa la existencia tanto de cereal como de leche, y están unidas por una conjunción lógica (la palabra "y"), en ese caso, tal proposición solo será verdadera en caso de que haya cereal y haya leche. Sin embargo, si no hay cereal, nunca será verdadera la proposición. Una decisión cortocircuitada me ahorra la vuelta al refrigerador a ver si hay leche, dado que es inútil que revise si hay leche. En programación, el uso inteligente de estos operadores lógicos me pueden permitir evitar muchos errores en tiempo de ejecución y también me pueden ayudar a agilizar el funcionamiento del código; por ejemplo, si yo antepongo una evaluación rápida sobre una variable sobre una invocación a una función de ejecución larga, al momento de resultar falsa la evaluación de la variable, el programa no hará la invocación larga. Recapitulando un poco: una condición cortocircuitada dejará de evaluarse en el momento donde el resultado ya no podrá ser alterado por las proposiciones siguientes. También aplica aplica para disyunciones: si estamos usando dos proposiciones en disyunción lógica del tipo "Si tengo suficiente para comprar un cartón o llamo a mis amigos para confirmar", la llamada nunca se realiza si hay suficiente para comprar el cartón, por que sin importar el resultado de la llamada, la proposición es verdadera.

En todo caso, aquí dependemos mucho del lenguaje que estemos usando, y de saber cómo maneja ese tipo de decisiones. Por ejemplo, Visual Basic necesita que sea explícito que una decisión es cortocircuitada a través de la palabra clave "AndAlso" mientras que Javascript, PHP, C++ y otros lo consideran automáticamente como cortocircuitada.

Iteración.

La iteración (que es una palabra bien lejana del uso coloquial) se trata acerca de repetir cosas; dicho coloquialmente, iterar es repetir, hasta que se cumpla una condición (el Fin de los Tiempos puede ser esa condición...). ¿Qué vamos a repetir? pues las instrucciones escritas dentro del bloque de código sobre el que estamos iterando, es muy importante cuando necesitamos repasar una lista de algo, analizar la entrada del usuario, o recorrer una tabla en una base de datos. En algún punto la iteración es la esencia misma de la programación como tal, ya que en el fondo, la computadora misma está iterando continuamente en espera de nuevas instrucciones para procesar (Les suena eso de que el microprocesador está a N gigahertz, pues esa es la velocidad a la que itera internamente el microprocesador).

En programación, existen dos formas tradicionales de iterar: los ciclos de ejecución fija (como contar hasta 10), y los ciclos de ejecución condicional (hazlo mientras que...). aunque sí, en el fondo, un ciclo de ejecución fija en el fondo es también un ciclo condicional.

Los ciclos de ejecución ¿fija? suelen ser conocidos en el argot como ciclos "for". En el caso de BASIC era muy claro que eran de ejecución fija por que se escribían tipo:

FOR variable = 1 TO 10; DO
    PRINT "Valor: " + variable
NEXT
 

sin embargo, en todos los herederos de C, está expuesta la condicional en la segunda sentencia del ciclo, pues se escriben más o menos así:


for (unsigned i = 0; i < 10; i++) {
    printf("Valor: %d", i);
}
 

En este caso, "i < 10" es una proposición lógica que se evaluará en cada vuelta que de el ciclo, mientras que la tercera parte establece el "paso de avance" que da el ciclo. Usualmente los ciclos de ejecución fija sirven cuando tenemos claro en qué momento deseamos detener la repetición de las instrucciones adentro del ciclo, además que nos dan un contador intrínseco que nos dice cuántas vueltas a dado nuestro ciclo. Semánticamente resultan más fáciles de leer para otros programadores. Son ciclos de ejecución fija los "forEach" ("por cada") que se aplican en programación funcional.

Los ciclos de ejecución condicional, en cambio, carecen de ese contador (no quiere decir que no se pueda agregar a mano), y van a seguir iterando hasta que la condición que está evaluando sea falsa. Son muy expresivos cuando se trata de marcar que vamos a dar vueltas pero no podemos saber (ni nos interesa) cuántas vueltas. Usualmente se usan para recorrer conjuntos de datos que vienen desde una base de datos, o también para hacer ciclos eternos (¿El Final de los Tiempos?). Aquí hay una cosa muy bonita que se puede aplicar, que tiene que ver con el punto donde ponemos la condición: tradicionalmente ponemos la condición al inicio en tipo "mientras que no se acabe la lista...", y si la lista está vacía (o sea, ya se acabó) no va a entrar al ciclo. Pero también podemos redactarlos en forma de "ve haciendo tal cosa mientras que haya personas", y esta segunda forma ejecutará al menos una vez las instrucciones, sin importar que la condición sea falsa previo a la entrada del ciclo.

La recursividad es otro tipo de iteración que consiste en invocar una función dentro de si misma. Es un tema tan complejo que merece su propio artículo.

Y por último, se empiezan a usar los eufemismos de la programación funcional para ocultar un ciclo de ejecución fija sobre conjuntos de datos (arreglos). Hay aquí muchos fanáticos que se obsesionan con el tema y consideran que son totalmente innovadores y revolucionarios y así. Pues nada, son ciclos condicionales, y aunque tienen ciertas ventajas a bajo nivel, la verdad es que siguen siendo ciclos y ya, pero más difícil por que ahora en lugar de hacer un solo ciclo y tomar tú las decisiones, tienes que ver qué clase de salida te va a dar la función que piensas invocar, y en lugar de tener una instrucción que te sirve para todo (maravilla), ahora tienes que tener en cuenta 6 o 7 que te dan resultados muy similares (pesadilla).

Invocación.

El último de los verbos es la invocación. Que sirve también para redirigir el flujo de ejecución, y que sirve para casi todo lo demás, pero no.

Antes de adentrarnos aquí, vale la pena hablar de lo que es y hace una "función", que no es otra cosa sino que un conjunto de instrucciones delimitadas, que realizan un trabajo específico y que pueden (o no) regresar un valor al lugar desde donde fueron invocadas. Las funciones, como tal, también pueden recibir información para realizar su trabajo, y lo hacen a través de "parámetros" (que son variables, pues... a las que se les cargan datos en la llamada a la función). Pese a que es un tema entero, quedémonos en que ese pedacito de programa puede haber sido definido por nosotros, o puede ser definido por el sistema operativo, el lenguaje, el intérprete, una biblioteca de terceros, etc.

Y ahora sí, para poder usar esos pedazos de código llamados funciones una y otra vez, usamos invocaciones, y ¡cómo no!, las invocamos por su nombre, y claro que tenemos que darles material para trabajar, en caso de que lo requieran.

En casi todos los lenguajes de programación es fácil detectar una invocación por que suele ser una palabra seguida de unos paréntesis. Dentro de esos paréntesis se ponen los valores con los que va a trabajar, o bien pueden ir vacíos en caso de que la función en cuestión no acepte parámetros, y se suele asignar su resultado a alguna variable.

Es también a través de invocaciones como nos comunicamos con bibliotecas de terceros, aunque no nos damos cuenta que estamos invocando funciones. Sin embargo, a la hora de depurar la máquina suele llevar un registro de la secuencia en cómo ha ido invocando funciones (se le llama "stack trace (pila de llamadas)") y eso es muy útil para saber cómo llegamos a ese punto del código, y de dónde vienen los valores que estamos trabajando, o de dónde NO llegan los valores; y esa pila de llamadas también nos ayuda a detectar errores de recursividad.

Un libro de Java que tengo por ahí decía "¿En qué momento es adecuado crear una función y luego invocarla?" y la respuesta era clara: "en el momento en que tienes que ejecutar el mismo conjunto de instrucciones al menos dos veces". En la vida real, luego a veces es práctico escribir funciones que se invocarán solamente una vez, pero que el hecho de que sean funciones vuelve el código mucho más legible que si estuvieran escritas todas las instrucciones juntas en lugar de la invocación. Así que las funciones también ayudan a darle legibilidad al código.

También hay que mencionar por adelantado una pregunta frecuente de los programadores novatos respecto a la forma en cómo se escriben e invocan funciones: sí, es por el orden de parámetros que se asignan los valores.

Por último, una especie de "desinvocación" es la instrucción "return" que suele devolver el contexto de ejecución al punto anterior después de que fue invocada una función; "return" acepta también como parámetro el valor que será regresado al contexto anterior.

Y el verbo perdido sería la redirección.

En las primeras versiones de BASIC existía una instrucción "GOTO" que enviaba a una línea específica, ya que no existían como tal funciones. Versiones viejas de C también soportan un GOTO pero usando etiquetas. Hace mucho que no se ven programas que use GOTO y no sabemos si lenguajes modernos lo pueden usar. Sin embargo, aún nos queda la instrucción "return" que interrumpe la ejecución de un bloque de código y devuelve un valor hacia el punto inmediato anterior a la pila de llamadas (o sea, al punto donde invoqué esa función). En estándar POSIX, el "return" que se marca al final de la función main() suele determinar el valor que recibirá el sistema operativo como resultado de la ejecución de nuestro programa (0 = no error, otros valores se interpretan como código de error).

Algunos programadores se vuelven hábiles usando instrucciones "return" en sus funciones para acortar el número de instrucciones ejecutadas por un programa, sin embargo, puede hacer que la depuración se complique y que otros programadores tengan dificultades para leer ese código.

Conclusiones.

El poder identificar claramente estas acciones posibles pueden ayudar a simplificar y mejorar sensiblemente el proceso de diseño de programas, disminuyendo la distancia entre el lenguaje natural que usamos los humanos y los lenguajes de programación que inventamos.

No hay comentarios.:

Publicar un comentario