martes, 14 de abril de 2015

La terminal en blanco y una historia de variables globales

En marzo publiqué una actualización de PSeInt, y tardé como 20 días (mayormente por mi culpa, por mi delay con los foros) en notar que a varios usuarios no les funcionaba ni la ejecución básica. Cuando querían ejecutar un algoritmo, la terminal quedaba en blanco; y si querían editar el diagrama de flujo, estaba vacío. Y así algunos problemas más, pero todas cosas gruesas, importantes, absolutamente impresentables.... Y entonces ¿cómo es que subí eso y no me di cuenta? He aquí una historia de variables globales y estáticas en C++, con moraleja conocida: evitarlas a toda costa.

El problema surgió a raíz de uno de los últimos cambios que hice para esa versión. Ese cambio en particular, tenía un bug, dado que el hecho de que funcione como yo esperaba, o simplemente reviente (es lo que pasaba con el intérprete por detrás de la terminal en blanco), dependía del antojo del compilador de turno. Y el compilador no tenía la culpa, ya que C++ le permitía (permite) compilar esos detalles que ya explicaré a su antojo (lo que se conoce como undefined-behaviour). El punto es que los compiladores nuevos parecen hacer las cosas como yo esperaba (solo por casualidad), mientras que los viejos que uso para empaquetar PSeInt para Linux y Mac no. Uso compiladores viejos, porque lo hago en sistemas completos viejos, para que no tengan dependencias de cosas muy nuevas y eso les impida funcionar en distribuciones más "conservadoras". Sin embargo, la mayoría de las pruebas las hago en sistemas nuevos. De hecho, para esa release había testeado PSeInt en varias distribuciones (más de lo habitual) por errores que me habían reportado y no podía reproducir en la mía (tanto de PSeInt, como de ZinjaI).


Lo que yo hice fue agregar en una clase llamada LangSettings que abstrae todo lo relacionado al perfil del lenguaje, un arreglo llamado data con la lista de cosas que se pueden modificar, cada una encapsulada en un struct que tenía un identificador a nivel de código, su descripción para mostrar al usuario, el nombre con que se graba en el archivo de perfil, el valor por defecto, etc. Todo para poder agregar nuevas opciones a los perfiles más fácilmente, desde un solo lugar. Pero este arreglo "data" es estático (es decir, una única instancia del arreglo compartida por todas las instancias de la clase) porque es el mismo para cualquier perfil (dice qué se puede configurar, no cómo está configurado). Los datos de este arreglo se cargan en tiempo de ejecución, en un método estático que debía invocarse sí o sí antes de hacer cualquier cosa con una instancia de LangSettings. Para evitar tener que hacerlo a mano (con el riesgo de olvidarme o hacerlo mal), agregué una bandera más a la clase, también estática, que decía si se había o no ejecutado esa inicialización. Luego, en el constructor de LangSettings, si la bandera decía que no, la ejecutaba. Entonces era imposible tener una instancia construida de LangSettings sin haber pasado antes por la inicialización. Simplificando, algo así:

    class LangSettings {
        static AuxData data[N]; 
        static bool esta_inicializado = false;
        static void Inicializar() { 
            data[0].init(....) data[1].init(...); data[N].init(...);
            esta_inicializado = true;
        }
    public:
        LangSettings() { if (!esta_inicializado) Inicializar(); }
         ...
    };

Y el detalle importante es que AuxData tenía su constructor, que ponía entre otras cosas sus punteros en NULL (para distinguir al depurar basura de no-inicializado). Luego, lo que desató la hecatombe fue crear una variable global de ese tipo LangSettings, llamada lang... Antes de seguir leyendo, pueden hacer el ejercicio de intentar imaginar por qué puede ser esto.

Para el compilador, un atributo estático, es más o menos lo mismo que una variable global... varía su scope para el acceso a la misma, pero no para su ciclo de vida. Es decir, las variables estáticas, al igual que las globales viven siempre y en todo el programa. ¿No? Es decir, ¿existen antes de empezar el programa? ¿Y quién las puso ahí?... Esta clase de dudas nos lleva a la verdadera pregunta: ¿dónde empieza realmente un programa C o C++? Y no, la respuesta no es "en la función main". Antes de entrar en la función main (que es donde usualmente asumimos que empieza todo), el programa debe inicializar todas las variables estáticas/globales, ya sea asignándoles valores constantes, o invocando a sus constructores. Pero he aquí el gran problema de que no se puede predecir ni controlar el orden en que esto ocurre cuando hay varias. O sea, en C/C++, realmente no podemos predecir cual es el "entry point" del ejecutable cuando están tipo de variables en juego.

Entonces, analicemos dos escenarios posibles para el ejemplo. Si el compilador decide primero construir el arreglo estático data y luego la variable global lang, el constructor de LangSettings reemplazará los valores por defecto de data con los que realmente corresponden, y todo funcionará a la perfección. Pero si el compilador decide primero construir la variable global lang, el constructor pondrá en data (con basura en ese momento) los valores correctos, pero luego al construir el arreglo data se perderán, y eso traerá problemas (como la terminal en blanco!). En este caso particular hay un mínima relación entre las dos variables que podría usar el compilador para determinar un orden, ya que una es arreglo estático de la clase de la otra (pero ojo que ni siquiera están en la misma unidad de compilación, sería trabajo extra del linker). Hasta donde yo se no tiene la obligación de hacerlo, y en el caso de que fueran ambas estáticas o ambas globales estoy seguro que no.

http://memegenerator.net/instance/53183236
Uno de los resultados al buscar "global variables" en google images

En conclusión, además de todos los problemas de "mal diseño" por polución del espacio de nombres global, falta de encapsulación, side-effects en funciones y métodos, problemas de sincronización en multithreading, etc, etc, que siempre se mencionan de las variables globales, hay que tener en cuenta este "detalle", porque el error es muy difícil de predecir, reproducir, y aún encontrar cuando se manifiesta. Esto no quita que mi viejo y triste código siga lleno de variables globales y atributos estáticos. Para publicar rápido una corrección, hice un parche no tan prolijo. Una solución general podría incluir al también difamado patrón Singleton, pero prefiero no ir por ahí. Para empezar voy a tratar de limitarlas solo a tipos de datos primitivos (enteros, punteros, booleanos) de forma que al menos no existan constructores que analizar ni posibles interdependencias problemáticas.

2 comentarios:

  1. con moraleja conocida: evitarlas a toda costa. (variables globales)

    Eso depende a que nivel programes, en el foro de PSEINT publique un muy simple código pero falla por que las variables de cada función son locales y parece a simple vista un programa que esta bien codificado, para poder realizar lo que quería usando subprocesos que llaman a otros procesos tuve que crear un arreglo para simular VARIABLE GLOBALES

    El proceso PRINCIPAL debe ser lo mas compacto posible, debe contener una declaración de variables, una función de lectura una función de proceso y una función de salida, pero para que las variables declaradas puedan ser usadas por estas subfunciones deben ser globales

    proceso principal
    definir primer_numero, segundo_numero como reales //y global
    obtener_entrada
    calcular_media
    imprimir_resultado
    fin proceso

    SubProceso obtener_entrada
    obtener_primer_valor
    obtener_segundo_valor
    //volver
    fin subproceso

    SubProceso obtener_primer_valor
    imprimir 'obtener un numero'
    leer primer_numero
    //volver
    fin subproceso

    SubProceso obtener_segundo_valor
    imprimir 'obtener un segundo numero'
    leer segundo_numero
    //volver
    fin subproceso


    SubProceso calcular_media
    media = ( primer_numero + segundo_numero )/2
    //volver
    fin subproceso


    SubProceso imprimir_resultado
    imprimir 'calculo aritmetico media:'
    imprimir media
    //volver
    fin subproceso

    ResponderEliminar
  2. Esta fue mi solución =(

    proceso principal // Inicio
    definir primer_numero, segundo_numero, media como reales
    // valores iniciales
    primer_numero = 1
    segundo_numero = 2
    media = 3

    dimension variables_globales[media] // [ primer_numero, segundo_numero, media ]
    definir variables_globales como reales
    // valores iniciales
    variables_globales[primer_numero]=0
    variables_globales[segundo_numero]=0
    variables_globales[media]=0



    obtener_entrada(variables_globales) // pasa el arreglo [ 0, 0, 0 ] a entrada
    //imprimir variables_globales[primer_numero] // para verificar primer_numero
    //imprimir variables_globales[segundo_numero] // para verificar segundo_numero
    calcular_media(variables_globales)
    //imprimir variables_globales[media] // para verificar media
    imprimir_resultado(variables_globales)
    fin proceso // Fin

    SubProceso obtener_entrada(variables_globales por referencia)
    variables_globales[1] = obtener_primer_valor // asigna el la primer posicion de variables_globales el valor del primer numero
    //imprimir variables_globales[primer_numero]
    variables_globales[2] = obtener_segundo_valor
    //imprimir variables_globales[segundo_numero]
    //volver
    fin subproceso

    SubProceso valor_a_retornar = obtener_primer_valor
    imprimir 'obtener un numero'
    leer primer_numero
    valor_a_retornar = primer_numero
    //retornar valor_a_retornar
    fin subproceso

    SubProceso valor_a_retornar = obtener_segundo_valor
    imprimir 'obtener un segundo numero'
    leer segundo_numero
    valor_a_retornar = segundo_numero
    //retornar valor_a_retornar
    fin subproceso


    SubProceso calcular_media(variables_globales por referencia)
    variables_globales[3] = ( variables_globales[1] + variables_globales[2] )/2 // media
    // volver
    fin subproceso

    SubProceso imprimir_resultado(variables_globales por referencia)
    imprimir 'calculo aritmetico media:'
    imprimir variables_globales[3] // media
    //volver
    fin subproceso

    ResponderEliminar