sábado, 6 de septiembre de 2014

Control de errores con C++

Hoy vengo a divagar sobre un aspecto en el diseño de interfaces para clases y/o funciones que siempre me genera dudas: ¿qué pasa cuando algo sale mal durante la ejecución? Muchas cosas pueden fallar, en diferentes niveles, algunos aceptables y otros no. ¿Cuándo retornar un código de error? ¿Cuándo simplemente retornar true/false? ¿Cómo manejar esto a nivel de código? ¿Cuándo usar excepciones? ¿Cuándo assert? ¿Cuando ignorarlo? Tengo que confesar con vergüenza que durante mucho tiempo vi a las excepciones como algo a evitar, una funcionalidad innecesaria de segunda clase (como un garbage collector :). Evité por años usarlas, y por ello no se ven hoy en día en mis códigos. Pero me voy dando cuenta de que estoy equivocado. No son la solución a todos los problemas, pero tienen lo suyo, y hacen cosas que otros mecanismo no. La idea es entonces analizar cada mecanismo de control de errores para tratar de entender dónde conviene usar cada uno.

Vamos por lo que parece ser el ejemplo más simple, una función que hace algo y retorna un código de error. Usualmente un simple true/false para tareas básicas, o un entero que permita discriminar un poco mejor entre más de dos opciones:
    bool func_que_puede_fallar(...) { ... } // retorna true si no hay error
    int funcion_que_hace_varias_cosas(...) { // retorna 0 si no hay error
       if (!paso_1(...)) return 1;
       if (!paso_2(...)) return 2;
       if (!paso_3(...)) return 3;
       return 0;
    }
El primer ejemplo es el más básico. Alcanza cuando no se quieren detalles de qué falló, y es simple de utilizar en otras funciones similares, como en la segunda función del ejemplo. Esta segunda da un poquito más de información, pero tiene dos problemas: no podemos hacerlo de forma tan simple cuando la función debe retornar un resultado útil, y además hay que andar preguntando por el valor de retorno cada vez que las usamos (lo mismo pasaba con la versión de true/false). Este mecanismo es, por ejemplo, el básico que provee un sistema operativo para la comunicación entre procesos (el return 0 del final del main), porque no requiere nada especial, y funciona como una simple función, pero no es tan cómodo y en muchas situaciones no aporta mucho.

Una alternativa, cuando por algún motivo no podemos disponer del valor de retorno para esto, es guardar el código de error en otro lado, para preguntar luego por separado:
    ifstream archivo("entrada.txt");
    if (!archivo.is_open()) exit(1); // error

Un ejemplo clásico es un constructor, o un destructor... son métodos que no pueden retornar valor alguno. Esta es una solución utilizada en algunas clases de la biblioteca estándar de C++ (como ifstream en el ejemplo), en muchísimas funciones de C (véase errno), y en muchas bibliotecas más muy usadas (por ejemplo, las primeras versiones de OpenGL). Pero, si controlar el valor de retorno a cada rato envolviendo las llamadas en un if podía ser molesto, peor aún con este mecanismo. Por ahora, ninguna de las dos opciones escala muy bien (en el sentido de componer soluciones a combinando bibliotecas). Y además hay detalles más complejos que analizar.

Pasemos entonces a las excepciones. Arrojar una excepción es interrumpir una función, haciendo algo como retornar un objeto muy particular, que no es el que decía que retornaba la función en su prototipo, y por lo tanto que no podrá se recibido como un resultado convencional el código que llamó a la función. ¿Entonces qué hacemos con eso? Hay dos opciones: o bien no hacemos nada (y el programa termina como si fuera un segfault, pero con un mensaje algo más personalizado), o bien preparamos una vía de escape alternativa en el código que invoca a la función:
    int suma_que_puede_fallar(int *p1, int *p2) {
       if (p1==nullptr||p2==nullptr) throw no_me_gustan_los_nulls_expection();
       return *p1+*p2;
    }
    int main() { 
       int *i1,*i2; ...
       try 
          s = suma(i1,i2);
       } catch(no_me_gustan_los_nulls_expection &e) {
          cerr<<"Danger! no pude sumar, supondré que dio 0"<<endl;
          s = 0;
       }
       ...      
En este ejemplo, la función recibe dos punteros que no deben ser nulos. Si alguno es nulo, lanza una excepción específicamente diseñada para eso. Si ninguno es nulo, lo cual supongamos que es esperable, la función retorna la suma como lo haría normalmente, y su uso no altera en absoluto la ejecución normal del programa. Si ocurre el error, el main puede hacer algo al respecto, como en el ejemplo con el try-catch, o ignorarlo como si no supiera que existen las excepciones ni los errores. Cuando una función ignora una excepción, se interrumpe, y pasa esa excepción a la siguiente en la pila de funciones. Si se llega al final de la pila y nadie se hizo cargo el programa finaliza anormalmente con una señal particular.

Entonces, ya vemos una primer ventaja del uso de excepciones: si no queremos control de errores (porque somos optimistas, kamikazes, o estamos muy seguros de tener todo bajo control), simplemente las ignoramos y ya. No pasa nada, el programa cliente compila igual, no hay costo adicional en la ejecución, etc. Es simplemente como si no estuvieran. Pero si de casualidad nos queremos hacer cargo de los problemas, podemos agregar un bloque try-catch en algún lado y proveer una salida de emergencia más elegante. Punto a favor para las excepciones.

Finalmente, otro mecanismo son los asserts. Tomemos aquí el assert clásico de C como referencia. Recibe un argumento de tipo bool, si es verdadero no hace nada, si es falso interrumpe el programa (y no hay forma de recuperarlo, es casi un como un segfault, pero informando la línea de código al salir). Es una medida un tanto drástica, que digamos que solo nos permite detectar un error, pero no realmente controlarlo. Entonces ¿para qué sirve? Pues, por ejemplo, para verificar los invariantes, esas cosas que pase lo que pase no pueden dejar de cumplirse:
    void limpiar_todo_el_arreglo(int *arreglo, int tamanio) { 
       assert(tamanio>0);
       for(int i=0;i<tamanio;i++) arreglo[i]=0; 
    }
En este caso, estamos diciendo que nadie en su sano juicio va a pedir inicializar un espacio de memoria de tamaño negativo, pues no tiene sentido, jamás va a pasar. ¿Y para qué lo verificamos si sabemos que nunca va a pasar? Es que en realidad sabemos que nunca "debería" pasar, pero cuando una función llama a otra función con el resultado de aquel método que recibió de ese otro argumento que le pasó el hilo que lanzó cuando leyó desde ese archivo que.... Es decir, cuando el problema se complica, si por algún error rebuscado llegamos a intentar sin querer hacer algo así, mejor detectarlo inmediatamente. Sin el assert, la función terminaría normalmente sin haber hecho nada, y el problema se tendría que manifestar más adelante, en otro lugar todavía más lejos de su raíz. Entonces, para estos casos, recomiendo un assert, para que el programa reviente lo más cerca posible de donde realmente se genera el problema.

La ventaja del assert, es que para la compilación en modo release podemos desactivar muy fácilmente todos esos controles simplemente definiendo una constante de preprocesador (NDEBUG). Sí, assert solo actúa en modo debug, pero es como si no existiera en modo release. Podemos entonces poner todos los asserts que se nos antoje sin miedo a que eso dañe en lo más mínimo el rendimiento de la aplicación final en modo release (y por si no se acuerdan o no lo vieron, comenté un alternativa mejor bajo cierto criterio, en este post de hace ya bastante tiempo, la maravillosa macro _revienta). Algunos critican el uso de asserts porque dicen que quitarlos en modo release es como entrenar para una carrera de motos con pechera, antiparras y casco, pero ir a la carrera sin nada de eso. Se puede discutir mucho y no es mi objetivo justo ahora, pero considerando que digo que usando un assert no se controla realmente un error, sino que solo se detecta (y suponiendo que esa es la intención con la que los usamos), ese argumento ya no pesa mucho. Cuando la analogía represente algo importante y posible en nuestra aplicación, probablemente debamos recurrir a excepciones.

Recapitulando, creo haber inducido algunos lineamientos a favor de las excepciones en ciertos casos, de los asserts en otros. Pero hay más detalles sobre el uso de excepciones que pueden dar lugar a ventajas adicionales, y que sería interesante seguir discutiendo, aunque los dejo para otro artículo.

No hay comentarios:

Publicar un comentario