Para empezar hay que saber que el preprocesador es uno de nuestros mejores aliados en estos asuntos. La compilación condicional permite que un programa actúe de una forma en modo debug y de otra en modo release. Entonces podemos ejecutar y depurar con todas las comodidades en nuestro entorno de desarrollo, y eliminar todas las sobrecargas del ejecutable final que ponemos en producción sin esfuerzo adicional. Por ejemplo, el siguiente código define una macro para imprimir mensajes por consola para usar como log solamente cuando la constante de preprocesador _DEBUG_LOG está definida, y no hacer absoultamente nada en otro caso.
#ifdef _DEBUG_LOG
#define _LOG(mensaje) cerr<<mensaje<<endl;
#else
#define _LOG(mensaje)
#endif
En nuestro IDE tendremos que definir en algún lado que queremos que el compilador utilice la constante de preprocesador _DEBUG_LOG al compilar en modo debug, y no al compilar en modo relase. En ZinjaI, por ejemplo, esta opción está entre las Opciones de Compilación y Ejecución del Pproyecto (menú Ejecutar->Opciones, arriba a la izquierda se elige el perfil Debug o Release, y en la pestaña Compilación hay un campo "Macros a definir"), y se traduce en el argumento -D_DEBUG_LOG para gcc en cada compilación.
Volviendo a las estructuras de datos, voy a tomar una malla como ejemplo. Una malla es normalmente un conjunto de polígonos/poliedros (triángulos, cuadriláteros, tetraedros, hexaedros, etc) en el espacio. Una forma básica de almacenar una malla consiste en tener un contenedor (arreglo o lista) con puntos y otro contenedor con elementos, donde cada elemento tienen referencias a los puntos que lo forman. Por ejemplo, en un elemento de tipo triángulo no guardo las coordenadas de sus tres vértices, sino referencias a tres elementos del contenedor de puntos. Esto es así porque en general los puntos son compartidos por varios elementos. En mi proyecto final de carrera implementé estas "referencias" como punteros; parece la forma más directa y eficiente de hacerlo, pero genera verdaderas pesadillas de direcciones de memoria cuando algo sale mal y hay que depurarlo. Mi director, Nestor, en su clase Malla implementó las referencias como simples enteros que indican una posición en el arreglo. Así se agrega un nivel de indirección más, ya que para obtener por ejemplo las coordenadas de un punto de un elemento, en el primer caso se las pido directamente al puntero con el operador ->, mientras que en el segundo tengo que ir primero al arreglo a buscar el verdadero punto (y tengo que tener fácil acceso al arreglo que no es un detalle menor), pero se simplifica enormemente la tarea de depurar. No es práctico intentar dibujar la estructura en papel mirando los inspecciones en el depurador, o imaginar la malla marcando cada nodo con una dirección de memoria que entre otras cosas podría no ser la misma en dos ejecuciones del mismo programa con los mismos datos. El índice en cambio se mantiene invariante, tiene un significado más concreto y permite en un golpe de vista saber si es válido o no. El incremento en el tiempo de ejecución que podría suponer esa indirección extra (que será constante, de unos pocos ciclos de procesador, y tiene otros beneficios colaterales) se paga solo y con creces. En el peor de los casos, se puede utilizar un set de macros que defina las cosas de una forma en la compilación debug y de otra en la compilación release. Entonces la primer moraleja del asunto es: no abuses de los punteros, analiza agregar un nivel más de indirección si esto ayuda a interpretar los datos en el depurador. Esto en general es válido cuando los contendores son arreglos (y si son dinámicos es obligatorio, ya que un realloc puede cambiar todas las direcciones de memoria), pero no cuando las estructuras son de tipo grafo/arbol donde los contenedores siguen una lógica más parecida a la de una lista enlazada y no se dispone de acceso aleatorio. En definitiva, todo esto no es más que un caso particular de un postulado más general que propuso Donald Knuth: "la optimización prematura es la raiz de todos los males", que solemos menospreciar cuando estamos a mitad camino de convertirnos en buenos programadores por sentirnos capaces de lidiar con la complejidad, pero que la experiencia se encarga de refrescarnos de mala manera de cuando en cuando.
(tomado de http://xkcd.com/371/)
#define _revienta(cond) if (cond) asm("int3"); asm("nop")
El nop (no operation) lo agregué para que el depurador marque efectivamente la linea del int3 ya que sino marca la siguiente, porque la señal salta luego de ejecutar el int3. Notar que por una cuestión semántica la condición funciona de forma inversa a la de assert, aquí se detiene si es verdadera. El único problema es que la forma de incluir instrucciones en ensamblador en medio de un código C/C++ no es estándar, sino que depende del compilador, y entonces tenemos que utilizar #ifdefs para elegir la que corresponda. El siguiente bloque de código funciona con gcc, mingw y Visual Studio en varias versiones, y tiene además un fallback que utiliza assert si no reconoce ninguno.
#ifdef _DEBUG
#if defined(_MSC_VER) // visual studio
#if _MSC_VER < 1310 // visual studio anterior a 2003
#define _revienta(cond) if (cond) __asm int 3;
#else // visual studio 2003 en adelante (__asm no funciona en 64 bits)
#include <intrin.h>
#define _revienta(cond) if (cond) __debugbreak();
#endif
#elif defined(__GNUC__) // gcc
#define _revienta(cond) if (cond) asm("int3"); asm("nop");
#else // para otros compiladores desconocidos, usar assert
#include <cassert>
#define _revienta(cond) assert(!(cond));
#endif
#else // en release no hace nada
#define _revienta(cond)
#endif
Un ejemplo bien simple donde utilizo esto es en la sobrecarga del operador [] de las clases que representen arreglos. Sabemos que C++ no verifica la valides de los índices al acceder a un elemento de un arreglo, así que utilizo este mecanismo para verificarlos "manualmente" solo en versión debug. Otro ejemplo es cuando una clase tiene un método para inicializar al que se debería llamar siempre antes de utilizarla para otra cosa (y por alguna razón no es el constructor, por ejemplo cuando se usa en un arreglo), aquí puedo usar assert para verificar la inicialización (ver por ejemplo que las variables dinámicas se hayan creado y que los atributos punteros no apunten a NULL). Hay muchísimas otras situaciones donde utilizar esta macro y en general tiendo a utilizarla cada vez más, aún donde me resulta claro que no es necesario, para protegerme de mis propios errores futuros. Todo programador con un mínimo de experiencia sabe que el código que hoy escribió tan claramente puede convertirse en jeroglíficos dentro de algunas semanas.
Hasta aquí la primer parte, en la segunda planeo comentar algo sobre asserts en tiempo de compilación (estos eran en tiempo de ejecución), manejo de señales, y otros trucos útiles que vi alguna vez.
Este tema continúa en Cómo prepararse para enfrentar un segfault (parte 2)
Genial!!! Muy útil lo de la interrupción!!! Gracias por compartirlo!
ResponderEliminar