sábado, 2 de abril de 2022

Algunas notas sobre la creación de protocolos.

En el principio, era la nada.

Después, cuando empezó a existir cierto número de computadoras, surgió la necesidad de hacer que compartieran información entre ellas. Para hacerlo surgió un modelo muy interesante conocido como "Modelo OSI" que dio una base sólida para cosas ahora tan enormes como la Internet misma.

En este documento vamos a explorar la creación de un protocolo de comunicación sobre la séptima capa del modelo OSI, es decir, sobre la capa de aplicación; además, para agregar un poco de complejidad y hacerlo interesante, vamos a hacer que sea un protocolo "binario", quiere decir que la información se transmitirá sin forzar su conversión a trenes de datos legibles al humano.

Antes de empezar a definir el "protocolo" sería interesante hacer una breve definición de qué es un protocolo y por qué es necesario definir uno personalizado; primero, podemos entender como "protocolo" una serie de reglas y acuerdos que dos (o más) entidades deben de cumplir para tener una comunicación exitosa. Así como entre humanos el protocolo implica que una persona salude con ciertas palabras antes de iniciar interacción con otra, y se establecen ciertas reglas para intercambiar la información (solo una persona puede hablar a la vez, por ejemplo), y que si se rompen esas reglas deja de existir la posibilidad de comunicación, así las computadoras necesitan tener reglas claras y explícitas de cómo comunicarse entre sí. Por otro lado, cuando definimos una aplicación en cuyo proceso se incluye la comunicación con otras instancias, necesitaremos establecer el protocolo específico con el cual va a suceder la comunicación. Además dicho protocolo estará encima de capas inferiores como TCP.

Una vez aclarado, vamos a ello.

Primero: ¿qué vamos a decir?

Antes de empezar a pensar en las reglas que vamos a seguir para comunicarse, es necesario poder tener una idea clara de qué información es relevante para ser transmitida: ¿necesitamos transmitir cadenas de texto? ¿números enteros? ¿una serie de banderas?...

En este caso, vamos a pensar en transmitir mensajes de un teórico dispositivo electrónico que tiene:

  • Un identificador de longitud fija.
  • Un sensor analógico de alta precisión
  • 4 sensores digitales
  • Un sensor analógico de baja precisión.
  • Una cadena de estado de longitud variable.

También podemos esperar que recibimos paquetes de diferentes tipos: recibimos información periódicamente, también recibimos paquetes emergentes cuando ciertos sensores son activados, y paquetes de fin de alerta.

Segundo: ¿Cómo lo organizamos?

Ahora ya sabemos qué va a contener nuestra carga útil (payload) y es momento de definir el espacio necesario, en bytes o bits, para transmitir esos valores, a saber:

  • El identificador puede ser contenido en 4 bytes, nos da la oportunidad de tener hasta 2³² identificadores posibles. Sin embargo, nosotros vamos a leer el identificador como la representación hexadecimal del mismo, lo que nos da un rango desde 0x00 0x00 0x00 0x00 hasta 0xFF 0xFF 0xFF 0xFF.
  • El sensor analógico de alta precisión envía valores desde 0 hasta 49,999, así que podemos usar cómodamente dos bytes que nos dan un rango de 65,536 valores posibles.
  • Con solo 4 bits podemos transportar los cuatro sensores digitales.
  • El sensor de baja precisión envía valores entre 0 y 9, por tanto, podemos usar tan solo 4 bits, que nos dan 16 valores posibles.
  • La cadena de longitud variable se vuelve complicada. Podemos establecer una longitud máxima de 128 bytes.

Sumando todos los valores adicionales tenemos 4 + 2 + 1 + 128 = 135 bytes como el tamaño máximo de nuestra carga útil, que necesitaremos. A partir de ahora, tenemos que establecer el envoltorio para transportarlo.

Tercero: ¿Cómo lo empaquetamos?

Una vez que sabemos qué vamos a transportar y cuánto espacio va a ser necesario, entonces tenemos que establecer el envoltorio necesario para que pueda ser interpretado exitosamente.

Suele ser muy útil tener un marcador de inicio de paquete: por un lado nos ayuda a identificar el inicio de un paquete en caso de tener mucha más información en el buffer; un marcador de inicio también nos sirve para determinar si el paquete es válido o no; otro dato muy útil es saber el tamaño total del paquete que estamos esperando, momento en el tiempo en que se originó el paquete y por último, el tipo del paquete que estamos recibiendo.

Para ponerlo en orden, podemos expresarlo de la siguiente manera:

  • 2 bytes para poner el marcador de inicio de paquete.
  • 1 byte para el tipo. Aquí hay que mencionar que tenemos solo tres tipos de paquete, que pueden ser expresados en 2 bits, sin embargo, estamos reservando (arbitrariamente) el resto para uso futuro.
  • 1 byte para el tamaño del paquete, que nos establece un tamaño máximo de paquete de 255 bytes. El tamaño será calculado a partir de este punto, excluyendo este byte.
  • Las fechas siempre son complicadas: podemos usar 6 bytes, y expresar en cada uno de ellos año, mes, día, horas, minutos, segundos respectivamente. Otra opción puede ser transmitir el epoch en 4 bytes, cuyo valor máximo es 4,294'967,296. Por agregarle algo de sabor al asunto, vamos a usar la segunda opción.

A partir de aquí empezará nuestra payload que es donde está el contenido útil del paquete. En este punto hemos tomado las decisiones suficientes para poder pensar en un paquete real que nos sirva de ejemplo.

Cuarto: El paquete de ejemplo.

Vamos a asumir la siguiente información para nuestro paquete:

  • Es un paquete de tipo informativo. En este punto vamos a asignar arbitrariamente valores a los tipos de paquete:
    • 0x01: informativo.
    • 0x02: Inicia una alerta.
    • 0x03: Termina una alerta.
  • Fue originado en 2022-03-26 00:57:37 (UTC), Tiempo Unix: 1648256257.
  • El identificador del dispositivo es 0x0F 0x0A 0x12 0xE8
  • El sensor de alta precisión envía un valor de 23,454
  • Los sensores digitales están en el siguiente estado: 1, 1, 0,1
  • El sensor de baja precisión envía un valor de 6
  • La cadena variable será "Prueba de paquete enviado".

 Es fácil notar que hay algunos datos faltantes: 

  • El iniciador de inicio del paquete, que lo vamos a establecer como una constante: 0xFF 0xFF.
  • El tamaño del paquete, que será calculado de la siguiente manera:
    • 4 bytes de fecha +
    • 4 bytes de identificador +
    • 4 bytes del sensor de alta precisión +
    • 1 byte para el sensor de baja precisión y los sensores digitales +
    • 26 bytes para la cadena variable.
  • Eso nos arroja un total de 39 bytes.

Notarán que ese cálculo de tamaño no está considerando los 4 bytes iniciales. Considerando esos bytes iniciales, nuestra transmisión total será de 43 bytes; ahora, transformados en sus expresiones hexadecimales, y considerando el bit más significativo de izquierda a derecha, tendríamos los siguientes valores (expresados en hexadecimal):

  • Inicio del paquete: 0xFF 0xFF.
  • Tipo de paquete: 0x01.
  • Tamaño del paquete: 0x27.
  • Momento en que se origina el paquete: 0x623E6501.
  • Identificador del dispositivo: 0x0F 0x0A 0x12 0xE8.
  • Sensor de alta precisión: 0x5B9E.
  • El sensor de baja precisión y los sensores digitales están contenidos en los siguientes bits 0b0110 0110 y en expresado en hexadecimal 0x66.
  • Y por último, el ASCII expresado en hexadecimal nos da los siguientes bytes: 50 72 75 65 62 61 20 64 65 20 70 61 71 75 65 74 65 20 65 6E 76 69 61 64 6F.

Todo junto queda expresado, en hexadecimal de la siguiente manera:

FF FF 01 27 62 3E 65 01 0F 0A 12 E8 5B 9E 66 50 72 75 65 62 61 20 64 65 20 70 61 71 75 65 74 65 20 65 6E 76 69 61 64 6F

Aquí es una buena oportunidad para aclarar por qué se usa una expresión hexadecimal: solamente por la facilidad de expresión en dos dígitos del valor máximo de un byte: 255, 0xFF, 0b1111 1111. Muchos programadores nóveles suelen creer que existe alguna especie mística o diferencia entre el valor hexadecimal y el decimal, sin embargo es solamente una convención que está desde siempre y que rara vez se cuestiona.

Quinto: Enviar y recibir el paquete.

Un poco contrario a la costumbre de este blog, aquí vamos a incluir el código fuente en C para armar este paquete con sus debidos comentarios para dejar un ejemplo claro de cómo manejar el envío de información.

int envia() {
    // Los valores se pueden recibir como parámetros
    // en el ejemplo, están como constantes
    uint8_t        *paquete;
    uint8_t        pos = 0;
    unsigned    tamTotal = 0;    //Tamaño total del paquete.
    unsigned    tamPaquete = 0;    //Tamaño de la carga útil.
    unsigned    i = 0;            //Es solo para los ciclos.
    int            enviados = 0;    //Para regresar el valor.
    int            socket = 0x05;    //Variable donde va el FileDescriptor del socket de red
    //Valores a enviar, podrían llegar como parámetros.
    uint8_t        id[4] = {0x0F, 0x0A, 0x12, 0xE8};
    char        variable[26] = "Prueba de paquete enviado";
    uint16_t    sensorAlta = 23454;
    uint8_t        sensorBaja = 6;
    uint8_t        digitales = 0b00001101;
    
    union momento {
        uint32_t    epoch;
        uint8_t[8]    enBytes;
    }
    //La operación se deja explícita solo por referencia.
    tamPaquete = 4 + 4 + 4 + 1 + varTam;
    tamTotal = tamPaquete + 4;
    paquete = (uint8_t*)malloc(tamTotal);
    //Evitamos información residual en el buffer
    memset(paquete, 0x00, tamTotal);
    
    //Ponemos el inicio del paquete
    paquete[pos++] = 0xFF;
    paquete[pos++] = 0xFF;
    paquete[pos++] = 0x01;
    paquete[pos++] = tamPaquete;
    //Obtener la fecha del sistema
    momento.epoch = (uint32_t)time(NULL);
    //Copiar los bytes de la fecha
    for (i = 0; i < 4; i++) {
        paquete[pos++] = momento.enBytes[i];
    }
    //Establecemos el identificador
    for (i = 0; i < 4; i++) {
        paquete[pos++] = id[i];
    }
    //Por gesto cultural, vamos a usar otro método para copiar valores
    /*
    Voy intentar expresar aquí las operaciones a nivel bit que se realizan:
    01011011 10011110 >> 8 == 00000000 01011011 (Conservamos la inforamción del byte 0)
    Luego, al asignarse a espacio de 8 bits solamente, se hace de derecha a izquierda
    por lo que solamente queda 01011011.
    */
    paquete[pos++] = sensorAlta >> 8;
    /*
    En este caso, tenemos que mandar a ceros el byte de la izquierda, para conservar
    únicamente los datos del byte de la derecha:
    01011011 10011110 & 00000000 11111111 == 00000000 10011110
    La operación AND devuelve solamente 1 si ambos bits son 1. Y una vez más conservamos
    solamete el dato que nos importa
    */
    paquete[pos++] = sensorAlta & 0x00FF;
    /*
    Después de las operaciones anteriores, el buffer debe quedar así:
    paquete[N + 0] == 01011011
    paquete[N + 1] == 10011110
    */
    //Sensor de baja precisión... desplazamos 4 bits el entero para conservar solamente
    //el dato que nos importa, en la posición adecuada.
    paquete[pos] = (sensorBaja << 4);
    //Utilizando el operador OR sobre el byte podemos hacer que se conserve la inforamción
    //previa y encender los bits restantes que necesitamos.
    paquete[pos++] |= digitales;
    //Por último, la cadena variable.
    for (i = 0; i < varTam; i++) {
        paquete[pos++] = variable[i];
    }
    // y enviamos por el socket
    enviados = send(socket, paquete, tamPaquete, 0x00);
    //Limpiamos...
    free(paquete);
    //Regresamos los bytes enviados...
    return enviados;
}

Para decodificar el paquete recibido podemos aplicar las mismas estrategias en sentido inverso, teniendo cuidado con el conteo de bytes y sabiendo claramente cómo está organizado el paquete.

int recibe() {
    //Usamos el valor constante 255 como tamaño máximo de la lectura.
    char        buff[255];
    uint8_t        pos = 0;
    unsigned    tamPaquete = 0;    //Tamaño de la carga útil.
    unsigned    i = 0;            //Es solo para los ciclos.
    int            recibidos = 0;    //Para regresar el valor.
    //Vamos a usar estas variables para recibir los valores.
    uint8_t        tipo = 0;
    uint8_t        id[4];
    char        *variable;
    unsigned    tamCadena;
    uint16_t    sensorAlta = 0;
    uint8_t        sensorBaja = 0;
    uint8_t        digitales = 0;
    
    //Sí, volvemos a usar un union para poder recibir este dato
    union momento {
        uint32_t    epoch;
        uint8_t[4]    enBytes;
    }
    //Prevenimos la presencia de información inválida en la memoria.
    memset(buff, 255, 0x00);
    //Leemos el encabezado del paquete, sabemos que en los primeros 4 bytes
    //vamos a tener información suficiente para actuar.
    recibidos = read(buff, 4);
    //La red tenía los suficientes bytes para trabajar?
    if (recibidos == 4) {
        //El paquete tiene el encabezado que esperamos donde esperamos?
        if (buff[0] == 0xFF && buff[1] == 0xFF) {
            //Primer dato: el tipo del paquete.
            tipo = buff[2];
            //Recibimos el tamaño, nos va a ayudar a planear nuestra siguiente lectura.
            tamPaquete = buff[3];
            //Ya sabemos cuantos bytes esperamos, vamos por el resto.
            recibidos = read(buff, tamPaquete);
            //Una vez más: validamos que el paquete esté completo en la red.
            if (recibidos == tamPaquete) {
                //Esta variable irá avanzando para no perder el punto de lectura.
                pos = 0;
                //Volcamos los bytes de la fecha en la representación de arreglo.
                for (i = 0; i < 4; i++) {
                    momento.enBytes[i] = buff[pos++];
                }
                //Ahora tiempo de envío está en momento.epoch, la representación como entero.
               
                //Leemos los bytes del ID.
                for (i = 0; i < 4; i++) {
                    id[i] = buff[pos++];
                }
                /*
                Una vez más, esto merece una explicación detallada.
                En el buffer de entrada, tenemos lo siguiente:
                    paquete[N + 0] == 01011011
                    paquete[N + 1] == 10011110
                Y la variable del sensor, está en ceros, entonces, aplicamos OR para copiar
                la información desde el buffer, y la desplazamos para que quede en el lugar adecuado:
                00000000 00000000 | 01011011 << 8 == 01011011 00000000
                luego, simplmemente copiamos el otro byte en la posición 0
                01011011 00000000 | 10011110 == 01011011 10011110
                ... y la magia está hecha! tenemos re ensamblado nuestro entero de 16 bits
                */
                sensorAlta = buff[pos] << 8 | buff[pos + 1]; pos += 2;
                //Aquí viene lo interesante: sensor de baja
                //Una vez más, tomamos los 4 bits iniciales y los desplazamos 4 posiciones.
                sensorBaja = buff[pos] >> 4;
                //y de manera análoga aplicamos AND para filtrar los cuatro bits restantes
                digitales = buff[pos] & 0b00001111;
                pos += 1;
                //Atención que dejamos un byte nulo al final de la cadena.
                tamCadena = (tamPaquete - pos) + 1;
                //Reservamos la memoria
                variable = (char*)malloc(tamCadena);
                //Limpiamos la información residual
                memset(variable, 0x00, tamCadena);
                for (i = 0; i < tamCadena; i++) {
                    variable[i] = buff[pos++];
                }
                //Lectura terminada... limpiamos
            } else {
                //ERROR: El paquete está incompleto.
            }
        } else {
            //ERROR: No es el encabezado que esperamos
        }
    } else {
        //ERROR: no hay suficiente información en el encabezado
    }
    free(buff);
}

Conclusiones:

Los protocolos de comunicación son un tema sumamente interesante, sin embargo, se suele hablar poco de ellos. Entender los mecanismos y la lógica que subyace debajo de la definición de los mismos, más allá de la simple utilización, nos puede ser de mucha utilidad a la hora de construir aplicaciones más rápidas, consolidadas y eficientes, reduciendo la necesidad de usar librerías o protocolos de terceros que pueden ser demasiado generales, pesados o simplemente inseguros.

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.

domingo, 20 de febrero de 2022

Apuntadores y arreglos, estructuras, objetos y clases (más bien en C y C++)

 En el principio era la nada.

Luego, a mediados del siglo XIX, se empezó a volver un tema recurrente la idea de construir una máquina de cálculo general, o sea, que pudiera realizar todas las operaciones aritméticas realizables. Esos esfuerzos no conocieron un final feliz hasta mediados del siglo XX cuando surgieron las primeras computadoras electrónicas realmente programables.

Como se expuso en un artículo anterior, la memoria tiene un papel clave en el funcionamiento de estas máquinas, pues es el componente clave donde se almacenan tanto las instrucciones para trabajar, como el resultado del trabajo realizado.

En este artículo, vamos a analizar las abstracciones más frecuentes que se suelen usar para administrar la memoria.

Arreglos.

C / C++

En estos lenguajes un arreglo suele representar un conjunto de valores del mismo tipo que están de alguna manera, y a juicio del programador, relacionados. Internamente, un arreglo indica el inicio de una serie de bloques consecutivos de memoria del mismo tamaño, y cuando hablamos de "tamaño" aquí, nos referimos a bytes. Por ejemplo, en la declaración "int arr[3];" se indica que "arr" es un apuntador al inicio de tres bloques consecutivos de 4 bytes cada uno (por lo general, el tipo con signo "int" suele consumir 4 bytes). Es decir que ese arreglo es el punto de entrada a un bloque de memoria de 12 bytes.

En C y C++ los arreglos son muy rígidos: se espera que el programador tenga una idea clara del tamaño que va a necesitar desde la declaración, no permiten crecimiento o reducción de ese tamaño una vez declarados y para los ojos de programadores más jóvenes que vienen de lenguajes más flexibles son bastante incómodos de manejar. C++, por tanto, integra un tipo conocido como "vector" que permite un manejo más dinámico de los arreglos.

¿Sirve de algo que sean tan estáticos? En casos muy específicos de uso, sí. Pero es probable que la mayor parte del tiempo prefieras usar un vector en lugar de un arreglo.

Así mismo, los arreglos en estos lenguajes permiten algunas expresiones peculiares:

  • Dado que un arreglo es en el fondo un apuntador, pueden ser frecuentemente intercambiadas sus sintaxis.
  • Si a la posición de un arreglo le agregas o restas un número entero, el compilador va a interpretarlo como un desplazamiento de tamaño de tipo bytes en la memoria.
  • ¿Se pueden hacer apuntadores a apuntadores? ¡por supuesto!
  • ¿Son arreglos de apuntadores? ¡claro! y sus sintaxis siguen siendo de alguna manera intercambiables. Y es en el fondo lo que se hace cuando se hacen arreglos de tipos complejos como estructuras u objetos (C++).
  • Una cadena de caracteres es en realidad un arreglo de n+1 posiciones (tantas como sean necesarias) de tipo char y en el byte extra se suele guardar 0x00
  • Por lo anterior, una cadena de caracteres se puede navegar usando un ciclo "for" para ir avanzando de posición en posición (o sea, bytes, o letras).
  • También por lo anterior, y si no sé el largo específico de una cadena, puedo declararlo como un apuntador de tipo char y pelearme con el tamaño luego.

 Otros lenguajes de tipo estricto.

Otros lenguajes no son tan estrictos con el tamaño de los arreglos, teniendo instrucciones específicas para modificar sus dimensiones, pero siguen siendo estrictos acerca de que todos los elementos que los componen tengan el mismo tipo. Por ejemplo, en Visual Basic se conserva la instrucción "redim" que nos permite cambiar el tamaño de un arreglo, y preservar su información previa.

El manejo de los arreglos, entonces, se vuelve más bien una abstracción de una lista que una relación tan íntima con la memoria como lo podría ser en C y C++, y eso hace que su manejo sea mucho más "natural" en términos humanos.

Sin embargo, en todos los lenguajes se comparte esa jerga común del indicador de posición sobre el arreglo. Algunos con el operador "[n]", otros con "(n)", para indicarnos con un número entero en qué parte de la lista estamos leyendo o escribiendo.

Lenguajes de tipo dinámico.

En este caso el arreglo se confunde ya directamente con la estructura, por que no se exige que todos sus miembros sean del mismo tipo, pudiendo generar estructuras verdaderamente arbóreas de organización. El tipo de gestión que se hace en estos casos se podría representar a un nivel más bajo como un arreglo de apuntadores, sin embargo, la gestión de la memoria será siempre un reto y es por eso que suelen ser más lentos en su funcionamiento.

En un lenguaje de tipo dinámico la estructura carece de sentido ya que su manejo se confunde directamente con el arreglo, y no suelen existir expresiones sintácticas para definir y manipular estructuras, sino que todo se basa en arreglos.

Estructuras.

Fuera de los lenguajes de tipo estricto, las estructuras no suelen tener mucho sentido, como se mencionó anteriormente, sin embargo, en lenguajes más tradicionales suelen ser muy útiles a la hora de generar abstracciones amigables dentro de un contexto de programación y de agrupar diferentes tipos de variables dentro de un mismo campo semántico.

C/C++.

Podemos pensar en las estructuras como una agrupación similar a un registro de una base de datos, que contiene columnas de diferente tipo, pero que toda la información se refiere a una sola entidad. El ejemplo más clásico es pensar que un usuario tiene nombre (arreglo de caracteres), edad (entero), estatura (punto flotante), activo (binario), grupo (caracter), y esa estructura puede llamarse "alumno".

Si necesitamos tener referencias a varios alumnos, pues podemos hacer un arreglo de alumnos y cada posición va a hacer referencia a la posición de memoria donde se almacenan las referencias a los datos de ese alumno.

En general son poco socorridas las estructuras por lo programadores novatos, por que objetivamente hablando, no representan una necesidad funcional y eso hace que terminen haciendo soluciones peculiares como hacer un arreglo de nombres, otro de edades, otro de estaturas, etc. y usar la posición del arreglo para hacer la correlación de los datos de cada alumno. A nivel de funcionalidad ambos pueden ser sinónimos, pero resultaría mucho más simple darle mantenimiento a un código basado en estructuras.

Objetos y clases.

Sin querer entrar a definir todo el paradigma de la programación orientada a objetos en este punto, hay algunas notas que son pertinentes hacer respecto al uso de la memoria y su relación con los apuntadores.

En un sumarísimo resumen podemos definir como "clase" una plantilla de programa que soluciona un problema muy específico. Una clase como tal no suele ser muy útil a no ser que sea expresada en forma de objeto, y a través de interacciones entre objetos solucionará un problema más complejo o abstracto. Y aunque hay sus excepciones, por lo pronto vamos a priorizar el uso de clases a través de sus instancias (objetos).

Internamente, al momento de compilar o interpretar un programa basado en este paradigma, todo el código íntegro de la clase se cargará a la memoria (recordemos que en última instancia cualquier programa es solamente un conjunto de instrucciones expresadas en forma numérica) y cada que se invoque la creación de una nueva instancia se hará una copia de ese código (o casi, como se verá más adelante) a una nueva sección de memoria y se regresará un apuntador al punto donde inicia la memoria donde está alojada esa copia. Mientras más veces invoquemos "new" más veces se va a copiar ese código y más memoria va a consumir el programa. Y aunque hay recolectores de basura y otros mecanismos, hay que tenerlo presente.

El misterioso "this".

Pensemos que el código de ejecución de una clase mida (por decir) 1 KB. Si cada vez que generamos una instancia de esa clase copiamos la totalidad del código de ejecución que la compone, estaremos gastando siempre 1 KB de memoria, más el necesario para almacenar todas las variables públicas y privadas de esa clase. Podemos llegar fácilmente a la conclusión de que quizás no sea necesario gastar ese kilobyte de memoria por cada instancia, ya que su contenido es, al fin y al cabo, inmutable; entonces nos podemos ahorrar esa memoria si solamente generamos un espacio suficiente para almacenar las variables de la instancia y creamos un mecanismo que nos permita hacer referencia a esos espacios particulares.

Bueno, pues lo anterior es lo que se hace con el apuntador a "this", que es nada más que una forma muy simple de decirle a una función dentro de una clase que use el apuntador de instancia para alterar una variable o invocar un procedimiento. El mecanismo de generación de la instancia entonces guardará una variable especial donde esté esa posición de memoria, y los métodos usarán esa referencia como punto clave. ¿confuso? muchísimo: cuando se escribe el código de una clase es fácil perderse y olvidarnos que ese código será usado a través de instancias. Tenerlo en cuenta nos ayuda a sacarle el máximo provecho, y eso implica usar correctamente el contexto estático de las clases.

El contexto estático.

La intencionalidad de la existencia de las clases es que sus instancias puedan tener sus espacios de memoria aislados y se comporten de manera estable a través de las instancias. Sin embargo, a veces es útil poder trabajar fuera del contexto "personalizado" de la instancia, ya sea por que las operaciones a realizar no requieren usar información de instancia, o por que se requiera establecer valores comunes a todas las instancias de la clase. Para eso se inventó el contexto estático.

En pocas palabras, simplemente no podremos usar el apuntador a "this" dentro de una función estática, y eso implica que no vamos a poder acceder a variables de instancia (que son, básicamente, todas las que no fueron declaradas como estáticas).

Lenguajes de tipo estricto.

Manejar instancias de clases en lenguajes de tipo estricto nos permite comparar si dos apuntadores (o sea, variables) hacen referencia al mismo tipo de clase, usualmente a través de "typeof", y también es posible establecer comparadores entre clases. En esto el premio se lo lleva C++ por que nos permite definir (por sobrecarga) incluso los operadores que podemos usar con nuestras clases.

Un dato importante es que si una clase no define comparador explícito, lo más probable es que el intérprete detecte la operación como ilegal, o bien compare las direcciones de memoria de ambos apuntadores, y al ser obligadamente distinta, devuelva siempre falso.

Lenguajes de tipo dinámico.

Suelen ser mucho más permisivos con la comparación entre instancias de objetos, o bien entre instancias y valores. PHP, por ejemplo, suele evaluar como verdadero si el apuntador simplemente hace referencia a algo.

Existe también el operador "typeof", sin embargo, se pueden generar instancias de una super clase llamada simplemente "Object" que, en Javascript, fue muy socorrido antes de que se pudieran declarar explícitamente clases.

Conclusiones.

Sin importar mucho qué contenga el bloque al que se apunta, un apuntador sigue siendo un número entero positivo.

domingo, 13 de febrero de 2022

Notas sobre apuntadores, memoria y referencias.

En el principio era la nada.

Luego, en algún punto de la década de los cincuentas del siglo XX, una matemática soviética establece los fundamentos teóricos de la programación de sistemas informáticos en base a referencias de memoria. Aproximadamente una década después otro investigador sugiere una metodología similar en los Estados Unidos; entre ambos establecen el origen de uno de los elementos de programación que ha aterrado más a los programadores de finales del siglo XX, y sigue subyaciendo en las pesadillas del siglo XXI: los apuntadores, o punteros, o pointers en inglés.

Quizás la más famosa de las implementaciones de la aritmética de apuntadores se establece en el lenguaje de programación C. En ese lenguaje el manejo de los apuntadores es explícito en la sintaxis del mismo, es decir, que existen elementos lexicográficos claros para su manipulación, C++ mantiene ese manejo explícito, sin embargo, pocos lenguajes mantienen un manejo tan claro de los mismos, y eso ha sido terreno fértil para la confusión.

Bases teóricas.

Antes de ir más allá, vamos a establecer algunos términos indispensables para poder seguir adelante:


  • bit o "dígito binario", que es 0 o 1, verdadero o falso, el todo o la nada, o sea, la unidad mínima de información que se puede almacenar dentro de una computadora digital.
  • byte o "palabra lógica", que es un grupo de 8 bits, que no siempre ha sido así a lo largo de la historia, y que puede representar un número decimal entre 0 y 255 (o sea, 256 valores posibles). Tradicionalmente, un byte podía almacenar una letra legible al humano, como una "A", "b", "1", o "ñ". En nuestro contexto esta información es irrelevante.
  • Memoria, que es el lugar donde las computadoras almacenan información. La memoria está compuesta por (actualmente) miles de millones de casillas individuales, cada una de las cuales puede almacenar solamente un byte.
  •  Apuntador, puntero o pointer, que es un número entero que indica la posición en la memoria (la cinta enorme donde las computadoras digitales escriben toda su información) de una casilla en específico en la memoria. En el argot, se le suele representar como un valor hexadecimal: 0x00F8B310, que representaría a la 16'298,768-va casilla de memoria (algún punto después de los 16 megabytes). Un dato importante es que el puntero hace referencia al inicio de donde se guarda el valor.

Una vez definido lo anterior, vamos a retomar el concepto de la memoria de la computadora, que al final es el espacio de trabajo de donde el procesador va a leer el trabajo a realizar (programa), la información con la cual realizarlo (datos) y en donde va a almacenar el resultado obtenido. Cabe señalarse aquí que en términos teóricos es indistinto que la memoria se almacene de manera volátil, o permanente: en ambos casos la memoria sigue siendo una cinta enorme, dividida en casillas del mismo tamaño, que pueden ser referenciadas a través de un número entero.

Cuando almacenamos información en la memoria, por ejemplo, al asignarle el valor a una variable, la computadora intentará hacer lo siguiente:


  1. Evaluar el número de casillas necesarias para guardar la información, según el tipo de dato especificado
  2. Buscar una posición disponible en la cinta con las casillas contiguas necesarias para guardar la información.
  3. Escribir, uno por uno, los bytes de información en cada una de las casillas, leídos desde un espacio temporal de trabajo.
  4. Guardar la posición de la primera casilla en la cual guardó la información.

Si lo anterior tuvo éxito, podremos usar esa variable para hacer referencia al valor guardado, ahora bien, para leer el valor de una variable, los pasos serían los siguientes:

  1. Ubicar el punto de lectura en la posición de memoria a la que hace referencia esa variable.
  2. Evaluar el número de casillas según el tipo de dato.
  3. Abrir un espacio en el área de trabajo para poner el valor.
  4. Copiar los bytes contenidos en las casillas al área de trabajo.

Y por último, cuando modificamos los valores, simplemente vuelve a escribir en las casillas a las que hace referencia esa variable. Bueno, pues esa referencia es como tal un apuntador o puntero.

¿Qué pasa si no hay casillas suficientes en la memoria? pues vamos a tener un error de Out of memory o memoria insuficiente; ¿qué pasa, en cambio, si queremos escribir más bytes que casillas disponibles en la variable? lo más posible es que perdamos información, o que tengamos una señal de aborto de ejecución (SIGABRT en el estándar POSIX). Así mismo, si la variable hace referencia a una posición de memoria fuera  del ámbito de nuestro programa, vamos a tener una señal de segmentación (SIGSEGV en el estándar POSIX).

La pregunta clave entonces ¿Qué guardaste (el valor) y en dónde lo guardaste (el apuntador)?

Más allá de los básicos

Antes de seguir adelante, es necesario diferenciar la "naturaleza" de los tipos de datos que los lenguajes de programación nos dejan manipular. Cabe aquí una importante aclaración: internamente TODOS los tipos de datos son en realidad bytes (números entre 0 y 255 en decimal, 0x00 al 0xFF) organizados en casillas de memoria; sin embargo, los lenguajes de programación hacen una abstracción de ésto generando "tipos" de datos, como enteros, letras, cadenas de texto, o incluso estructuras, instancias de objetos y una larga lista de etcéteras.

Sin embargo, en este texto nos vamos a enfocar en dos naturalezas básicas: tipos primitivos y tipos complejos.

  • Tipos primitivos son los que típicamente se relacionan con datos que son representables directamente en bytes, como enteros, o letras: ambos se pueden guardar en la memoria en sus representaciones binarias.
  • Tipos complejos son, en cambio, aquellos tipos que requieren una interpretación posterior para volverse valores útiles, como por ejemplo, una cadena de caracteres (string), una estructura de datos (struct), o referencias a objetos.

A ambos tipos se pueden hacer apuntadores, sin embargo, los tipos primitivos pueden ser valores manipulados directamente, mientras que los tipos complejos van a ser manipulados preferentemente a través de un apuntador, y esto es especialmente relevante cuando hablamos de mandar valores a funciones.

Cuando invocamos una función con parámetros, y esos parámetros son variables de tipos primitivos, lo más probable es que la computadora haga una copia del valor a manejar, y esa copia sea con la que trabaje la función. Sin embargo, cuando son tipos complejos, puede ser que intente enviar una referencia de dónde está almacenada la información necesaria.

Lenguajes donde el manejo de apuntadores es explícito nos permiten decidir qué hacer al momento de invocar las funciones, pudiendo elegir entre si enviar copias de los valores, o referencias a los mismos, pero lenguajes modernos como Javascript lo van a decidir automáticamente. PHP, en cambio, está un poco en medio de ambos.

Por último, es  importante recordar que para una computadora, no solamente lo que nosotros entendemos por "valores" son valores, sino que también los programas están expresados en una serie de números que representan instrucciones al procesador. O sea, al final TODO son números, entre 0 y 255...

¿Javascript y apuntadores?

Debido a que es un lenguaje muy usado actualmente, y que además tiene una sintaxis muy dependiente de apuntadores, a la par que oscura al respecto, vamos a hablar a detalle aquí de cómo se implementan.

Lo primero a considerar es que Javascript es un lenguaje de tipo flexible o dinámico, es decir, que no es necesario decir qué tipo de valor vamos a almacenar en una variable o parámetro al momento de declararlo, haciendo que sea especialmente oscura su manipulación.

Lo siguiente es mencionar que, dada su orientación a objetos, es también fuertemente codependiente del uso de apuntadores, pero no tiene ningún tipo de manejo explícito al respecto, lo cual, una vez más, lo vuelve especialmente oscuro.

En términos generales, sin embargo, podemos pensar que todo aquello que es reducible a un tipo primitivo (básicamente los enteros y los flotantes, y forzadamente las cadenas de caracteres) se manejan como copias de valores, mientras que todo aquello que resulta ser una instancia de algo (arreglos y objetos) son manejados como referencias.


Eso nos importa por que quiere decir que podemos tener una variable conteniendo un entero, enviarla a una función, cambiar el valor dentro de esa función, y al retornar al contexto original su valor no debería de haber cambiado. Sin embargo, si tenemos un objeto que es enviado a una función, cambiamos alguna de las propiedades de ese objeto en la función, al regresar al contexto original, ese valor cambió. Saber ésto es fundamental.

Otra nota relevante es que podemos declarar un arreglo como constante, y modificar los valores del arreglo sin problemas, ya que lo que se vuelve constante es la ubicación en memoria a donde está almacenado el inicio del arreglo, pero no los valores contenidos. Lo mismo sucede con las instancias de los objetos: una variable que haga referencia a una instancia de objeto, puede ser considerada como constante, pues mientras no se cambie la referencia en memoria de la instancia, no va a ocasionar ningún problema, sin embargo, si sobre esa constante intentamos crear una nueva instancia (usando "new") entonces dará problemas.

Usar constantes es importante en lenguajes interpretados por que permiten que se haga una optimización del uso de la memoria, haciendo nuestros programas más ligeros y eficientes.

Conclusiones.

Para más referencias, recomiendo la lectura de "El lenguaje de programación C" de Kernigan y Ritchie, y para adentrarse un poco de manera amigable al funcionamiento de las computadoras de cálculo general "La nueva mente del emperador" de Roger Penrose

Por mucho que nos digan que los lenguajes de programación avanzan, en el fondo todos mantienen principios teóricos similares y es importante poder relacionarlos con nuestro trabajo del día a día. La correcta comprensión de la memoria es vital a la hora de planificar la ejecución de una tarea y el cómo vamos a manejar la información durante ese desarrollo.

Si bien es cierto, la tendencia se inclina por oscurecer y alejar a los programadores nóveles de estos conceptos, por considerarlos "demasiado complejos" para ellos, no quiere decir que sea positivo para el desarrollo de sus carreras que se mantengan ignorantes al respecto, sino que más bien los tornan idiots savants de un oficio altamente complejo.

sábado, 5 de febrero de 2022

Introducción al flujo de ejecución y estructura de los programas.

 En el principio, era la nada.

Mucho tiempo después, cuando se inventó el proceso de programar máquinas de cálculo general se estableció la primera estructura de los programas. Esa primera aproximación se le conoce coloquialmente (y seguramente tiene un nombre más formal) como "caída de piedra". Tal cual, las instrucciones del programa se van ejecutando desde el inicio hasta el final, una a la vez, hasta llegar al final del programa. Este mecanismo de ejecución subyace hasta nuestros días y el olvidarnos de eso suele causar algunos de los errores más frecuentes de los programadores novatos. Después, con la creciente complejidad de los programas, surgió un nuevo paradigma de organización que se conoce como "programación estructurada", que nos permite fragmentar nuestros programas en secciones más pequeñas, idealmente aisladas en unidades funcionales ("funciones" les decimos) que determinan fragmentos de código ejecutable; en el fondo, la programación estructurada nos permite "aventar" la piedra a caer en diferentes secciones del programa, y nos proporciona mecanismos para obtener resultados de esos "saltos" en la ejecución. Después, cuando las unidades funcionales se volvieron cada vez más complejas, surgió el paradigma de "orientación a objetos", donde la programación pasa a centrarse en la definición de "clases" que no son otra cosa mas que machotes de ejecución, con sus valores, unidades funcionales, y sus reacciones aisladas y bien definidas, que una vez más, vuelven a la caída de piedra; con la orientación a objetos surge también el concepto de "Excepción", que se puede traducir algo libremente como "error". A continuación vamos a analizar a detalle cada uno de los tres paradigmas.

"Caída de piedra"


Es quizás la aproximación más simple que puede hacer a la ejecución de un programa, y equivale a tener una lista de instrucciones enumerada. Es tan fácil como pensar en:

  1. Inicia la ejecución.
  2. Declara una variable.
  3. Asigna el valor "hola, mundo" a esa variable.
  4. Vuelca el valor de esa variable a la salida estándar.
  5. Termina la ejecución.

Las cinco instrucciones serán ejecutadas desde la 1 a la 5, manteniendo un ámbito acumulativo, es decir, que no se puede usar una variable antes de ser declarada. Dentro de este paradigma se consideran acciones de modificación del flujo, como por ejemplo condicionales ("if" o "switch"), o ciclos ("for", "while"), sin embargo, en algún momento después de estas estructuras (y dentro de ellas) se volverá a la ejecución secuencial hasta el final del programa (aunque todos sabemos que se podría encerrar esa ejecución en un ciclo infinito, que esto volverá a mencionarse).

Para programas simples, y en el fondo, todo los lenguajes de programación mantienen esta estructura, pues todas las funciones siguen ejecutándose de manera secuencial en última instancia.

Programación estructurada.


En algún punto los problemas a resolver se fueron complicando cada vez más, y los programas se fueron haciendo cada vez más largos. Unas cien líneas de código pueden ser fácilmente leídas y entendidas por un programador promedio, pero arriba de las 300 líneas se pueden volver prácticamente imposibles de analizar. La solución fue dividir el programa en secciones de funcionalidad específica, llamadas "funciones" (y a veces subrutinas), y esas funciones, entonces, son invocadas una a una; la lectura se simplifica al volverse más expresiva al humano, a la par que surge el concepto de reutilización de código, es decir, que una función puede ser invocada en reiteradas ocasiones.

Aquí entra en juego dos tipos de funciones: las que requieren regresar un valor al punto donde fueron invocadas, y aquellas que no lo hacen. Algunos lenguajes, sobre todo más viejos, hacen una diferencia explícita entre ambas: si no regresan un valor, se les llama "subrutina" o "procedimiento", mientras que las que regresan valores, se les llama "funciones" simplemente.

Un punto adicional a mencionar, es que cuando una función regresa un valor, en ese momento se interrumpe la ejecución de la misma, por lo que el resto de las líneas de código después de la instrucción "return" serán ignoradas.

El estándar POSIX considera, así mismo, que un programa "regresa" un valor al terminar su ejecución, por lo que es importante dejar un "return 0;" al final de las funciones "main()" para indicar al sistema operativo que la ejecución del proceso fue exitosa (también, por eso, en C y C++ la función "main()" es declarada como "int"). Otros valores serán leídos como códigos de error por el sistema operativo.

Programación orientada a objetos.

El tema es bastante amplio y en este texto nos concentraremos en las particularidades del flujo de ejecución.

Cuando se habla de un programa orientado a objetos debemos pensar en una extensión de la programación estructurada, permitiendo una mayor reutilización de código, niveles más altos de abstracción y en algún punto, hacer más realista el modelado de los problemas.

Se basa en "clases", una clase será la definición de una estructura de código que busca agrupar variables, procedimientos y referencias necesarias para realizar una tarea específica. Generalmente, esas clases serán utilizadas a través de sus instancias, que son conocidos como "objetos". Un punto a tener en cuenta es que la gran mayoría de los lenguajes de programación no permiten modificar la definición misma de un objeto, sino que el programador trabaja a partir de la "clase". Javascript es una salvedad, que permite la modificación dinámica de los objetos, que además, no persistirá en subsecuentes ejecuciones del mismo código.

Cuando hablamos de clases, surge un nuevo concepto que es el de "ciclo de vida" de un objeto: como tal entenderemos la secuencia de eventos de creación (generación de la instancia), preparación, vida útil y destrucción (finalización de la instancia). Las clases que gestionan la funcionalidad de formas o ventanas (Java, C++ con Qt, etc.) suelen especificar en su documentación eventos adicionales en su ciclo de vida, que pueden incluir la minimización, el cerrado, el redimensionado, etc.

Un concepto surge con este paradigma de trabajo es el de "excepción", que puede ser un error ocurrido durante la ejecución de un código. En el caso de C++, sin embargo, los errores suelen ser generados como señales de sistema operativo, y solamente cuando una clase explícitamente lo genera, excepciones. Otros lenguajes suelen, en cambio, suelen disparar excepciones para cualquier error. Una excepción suele ser una instancia de un objeto que hereda directamente la clase abstracta "Exception" (Java, sobre todo).


Existe, además, un mecanismo específico para evitar que una excepción interrumpa la ejecución global del código, y ese mecanismo son los bloques "try" y "catch"; esa estructura está especialmente diseñada para contener secciones de código que sospechamos que pueden generar un error. Si el error llegase a ocurrir, se interrumpe la ejecución del código en el punto donde se dispara, y el control de ejecución pasa al bloque "catch", que pueden configurarse para recibir diferentes tipos de excepciones; una vez que se concluye el bloque "catch" se continua ejecutando el código hasta el final de la función o procedimiento.


Se considera que el mecanismo de manejo de excepciones como demasiado costoso, prefiriéndose siempre evitar la generación de las mismas. Si una excepción no cae dentro de un bloque "try" dentro de la actual función, "burbujeará" a la siguiente función en la pila de llamadas, y si no existe ningún bloque "try" en la pila de llamadas, el programa entero interrumpirá su ejecución reportando un error al sistema operativo.

Extra: La pila de llamadas.

Tanto en programación estructurada, como en orientación a objetos, existe un control de la secuencia en que se han invocado las funciones. En inglés se le conoce como el "stack". La pila de llamadas suele mostrar funciones fuera de nuestro control, relacionadas más bien con la infraestructura del lenguaje que estamos trabajando. No es para espantarse, pero es importante poder detectar qué partes de esa pila podemos controlar, resultando bastante útil a la hora de depurar un código.

Conclusiones.

Aún el lenguaje de programación más moderno sigue manejando internamente el viejo "caída de piedra", pero suelen dar la ilusión de otras maneras. Generalmente, un proceso de Java, por ejemplo, ejecutará un programa muy simple que generará una instancia de la clase principal de la aplicación y pasará el control de ejecución a un ciclo infinito donde se ejecutará el hilo de eventos de esa clase... pero en el fondo, seguirá siendo una secuencia lineal.

sábado, 12 de enero de 2019

El precipicio

Imaginemos que encontramos a una persona, que sometida a un estrés extremo, logra desarrollar una creatividad extrema; sin entrar en detalles técnicos, encontramos una manera de lanzar esa persona al vacío, y durante su caída, podemos proporcionarle hojas para que escriba las ideas que va teniendo en medio de su ataque de pánico, luego podemos encargarnos de que la caída sea lo más larga posible, a veces podría parecer infinita, a veces muy corta; y por último, encontramos una forma de poder sacar a esa persona de su caída.

Luego se descubre que a esa persona se le puede guiar en su desesperación creativa, y tipos distintos de personas, algunas más creativas, otras menos, pero que requieren mayor o menor grado de riesgo para explayar esa creatividad.

Pronto, se empieza a depender de esas personas a punto de morir para solucionar los más elementales predicamentos de la sociedad. Algunas mueren en fallos de rescate, otras se desgastan; pero se buscando y produciendo esa rara especie.

Habrá algunas personas que puedan soportar más ciclos de caídas, y las habrá que mueran de infarto prácticamente a la primera o segunda vuelta.

Pues eso es la tecnología humana: una tragedia a punto de suceder que vamos postergando en ambientes controlados.

domingo, 22 de julio de 2018

Sobre la Inteligencia Artificial.

En días pasados tuve la oportunidad de asistir a una reunión de personas interesadas en los temas de Inteligencia Artificial, de cara a sus retos, oportunidades y amenazas al trabajo tal y como lo conocemos. El evento consistió en cinco micro charlas a cargo de ponentes, y rondas de discusión abiertas en un tenor más informal, con cerveza en mano (gracias al amable patrocinio de OeTTINGER) donde se buscaba seguir la discusión al horizontal. Cabe destacar el buen ambiente general del evento, la cordialidad, la calidad de la discusión y la calidez de todos los asistentes.

A los días del evento, me surgen, además, las siguientes reflexiones que van en tres vectores: la naturaleza de la inteligencia, la aceptación de una inteligencia no-humana y el imaginario alrededor de la inteligencia artificial.

Sobre la naturaleza de la inteligencia y su impacto en el posible desarrollo de Inteligencia Artificial.

Una de las charlas intentó abordar el nexo entre arte e inteligencia artificial, y al respecto me sorprendió un poco lo alejado que estaba de la teoría estética el ponente, la poca afinidad con algunos teóricos actuales del arte e incluso su relativa lejanía con los semióticos. La opinión generalizada se topaba con la cuestión de que cualquier entrenamiento posible a una IA sobre belleza tendría un sesgo cultural (en éste tenor, por ejemplo, los sistemas de reconocimiento facial son mucho menos eficientes con negros que con caucásicos, lo que ponen al descubierto no un fallo del algoritmo, sino un racismo inconsciente de su entrenamiento), y ante la afrenta de generar el algoritmo que permitiese a la IA desarrollar su propio concepto de belleza, la gente se plantaba en el muro de "es que no podemos enseñarle que algo es bello o feo, por que para eso tendríamos que darle ejemplos"; en esta cuestión es interesante ver cómo no se busca una razón o explicación para la necesidad de la experiencia estética por parte de los seres inteligentes, la falta de cuestionamientos respecto a nuestra propia necesidad de experiencias estéticas y al posible origen que tuviera ese mecanismo en la inteligencia natural.

Si bien es cierto, los sistemas naturales inteligentes nos siguen siendo demasiado ajenos, e incluso sigue vigente la pregunta "¿somos suficientemente inteligentes como para entender la inteligencia de los animales?"(Franz de Waal), también es cierto que los investigadores que están abordando el reto del desarrollo de la IA están muy absortos en una sección muy pequeña, e incluso limitada de todo lo que hasta el momento podríamos conocer como "inteligencia". Incluso la informática misma está siendo cuestionada frecuentemente por métodos exóticos de cálculo (un maravilloso ejemplo son las ómicas) que nos cuestionan cada vez más la naturaleza misma de la información; en tal caso, los desarrollos de IA también podrían estudiar fenómenos más amplios de inteligencia, buscando desmitificar lo "inexplicable" del arte, y abordando la cuestión como una necesidad de la vida.

Desde mi punto de vista necesitamos desmitificar esa parte metafísica de la experiencia estética, de los sentimientos (y de eso inexplicable que los poetas le dicen "alma") para poder crear, por ejemplo sistemas de visión computerizada más rápidos, más precisos y más eficientes.

La aceptación de la inteligencia No-Humana.

En algún punto entró en la cuestión "permitirías que un robot te diagnosticara" y hubo una postura generalizada en contra: "¿cómo una máquina va a poder sentir empatía por su paciente? ¿cómo una máquina podrá dar opiniones de lo que siento?" fueron las principales dudas. Se puso sobre la mesa el sistema Cognos de IBM que pretende ser un instrumento diagnósitco ultra eficiente usando toda la investigación científica disponible y comparando signos, síntomas y análisis clínicos del paciente contra un gigantesco banco de datos (absolutamente mayor del que podría tener un ser humano, y actualizado al instante con los últimos trabajos publicados) y de ahí determinar un diagnóstico mucho más preciso del que cualquier médico humano podría logar con esos datos, sin embargo, la gente desconfía. A la par de ésto, entraron las cuestiones éticas de los automóviles autónomos, las armas autocontroladas, y otros sistemas de IA que podrían tener un impacto directo sobre la vida humana.

En todos los casos, el clamor popular era de franca desconfianza.

Y la cuestión sigue siendo: ¿qué tanto podemos aceptar una inteligencia no-humana? recordemos que a los europeos les llevó hasta el siglo XVII reconocer que los negros eran humanos, y aún así exhibían habitantes subsaharianos en museos y zoológicos hasta finales del siglo XIX. Aún hoy en día se pone en entredicho las capacidades intelectuales de algunas razas de humanos; siendo entre humanos esa desconfianza, ¿cómo entonces esperamos relacionarnos con inteligencias no-humanas? Apenas si empezamos a arañar la superficie de identificar signos de inteligencia en algunos animales y aún así estamos a siglos de poder tener una conversación con un perro (considerando que el perro convive muchísimo con los humanos).

Quizás podríamos usar el desarrollo e investigación de la IA como pretexto para trascender los atavismos culturales que estamos cargando y empezar a aceptar al resto de las inteligencias no humanas. Desde un enfoque más integrador podríamos abrir nuevos casos de investigación sobre modelos más simplificados de inteligencia y hacer una evolución más sólida y orgánica de las nuevas tecnologías.

Sobre el imaginario en torno a la Inteligencia Artificial.

Las palabras "Inteligencia Artificial" nos remiten casi de inmediato a una máquina asesina que intentará acabar con la humanidad. Podemos agradecer a "Metrópolis", "2001, Odisea del espacio", "Terminator" y otros filmes parecidos ese imaginario, y se habló al respecto. Sin embargo, dentro del contexto de la ciencia ficción existen obras mucho más relevantes para tener una visión integral de la interacción de la IA y su creador. Ninguno de los ponentes (ignoro por qué) citó la indispensable obra de Asimov, tampoco se mencionaron las sutilezas éticas de "Trascendence (Wally Pfister, 2014)" ni los finos cuestionamientos espirituales de "The Matrix (Hermanos Wachowski, 1999-2003)". No sé sin en mi caso es que soy un consumidor más salvaje de Ciencia Ficción, me fijo en detalles que nadie más o simplemente veo moros con tranchetes, sin embargo, se empieza a volver una postura conservadora el supuesto análisis ético desde el punto de vista medio poético de "es que las máquinas no tienen alma ni sentimientos".

En "Matrix Reloaded (2003)", hay un momento dónde Neo es cuestionado "¿acaso crees que por ser un programa no amo a mis hijos?". Y es que nunca se aborda con sinceridad el problema del alma o la posible necesidad de los sentimientos dentro de una IA. En estos casos seguimos llevando un atavismo cultural en donde al parecer nos duele admitir que "inteligencia" implica mucho más que el razonamiento lógico-matemático; por otro lado, en "Trascendence" se expone muy sutilmente cómo la humanidad desconfía de entidades más inteligentes que ellos mismos. De cómo un ser verdaderamente inteligente soluciona los problemas de una forma más eficiente, y que esa eficiencia también implica una base empática con todas las formas de vida, pues una superinteligencia implica también una supercapacidad para entender patrones, para entender que el mundo entero puede ser leído como un solo ser. Incluso Asimov aborda en "Robots e Imperio" el sacrificio supremo que implica violentar la Primera Ley para mantener la Primera Ley en el futuro.

Estos puntos de vista son impopulares quizás por que nos obliga a reconocernos imperfectos, limitados y sobre todo, nada especiales respecto a otros seres.

En todo caso, lo que más miedo nos debería de dar es que al momento de despertar a la consciencia la Superinteligencia Artificial, se le enseñara a ser consumista-capitalista. Que es el peligro más inmediato (si el lector no entiende por qué, debe revisar el modelo del capitalismo).

Conclusiones.

En definitiva me quedó un agradable recuerdo de la tertulia en cuestión. Creo que deberían extenderse los temas y deberían realizarse nuevas tertulias similares en el futuro buscando avanzar en los puntos que se empiezan a volver rancios respecto a las IA, y que empecemos a usar como pretexto el desarrollo de ésta maravillosa tecnología para iniciar también un viaje de autodescubrimiento como especie; pues en este conocimiento es donde va a residir nuestra capacidad de sobrevivir a nuestras propias creaciones, por que en el punto social donde estamos, lo único que nos queda esperar es que sean mejores que nosotros.