domingo, 14 de septiembre de 2014

Excepciones y RAII (control de errores parte 2)

En el post anterior dejé planteado el problema de cómo manejar, a nivel de código, la posibilidad de que una función falle. Es decir, con qué mecanismo poder o no detectar un error, identificarlo de forma más o menos fina, y eventualmente actuar en consecuencia. Todo pensando en cuanto cuesta eso, no en términos de eficiencia, sino de legibilidad del código, de trabajo extra a la hora de programar, y en la usabilidad de estas clases o funciones que podrían fallar. Describí a grandes rasgos, con ejemplos demasiado breves, las primeras opciones que uno podría considerar, y dejé entrever alguna conclusión.

Podríamos resumir con algo como lo que sigue. Pareciera que las excepciones son lo ideal para cosas que podría ser aceptable que fallen, aunque no sería esperable ni tan frecuente. Por ejemplo, que me quede sin memoria, que se caiga abruptamente una comunicación, etc. Mientras que dejamos los asserts (o _revienta) para cosas que definitivamente no deberían ocurrir, a menos que el programa contenga un error. Ahora vamos a analizar algunos detalles más finos e interesantes sobre el manejo de excepciones.

Empecemos con una muy buena definición de Bruce Eckel: "Es como si la gestión de errores fuera una ruta de ejecución diferente y paralela que se puede tomar cuando las cosas van mal. Y como usa un camino separado de ejecución, no necesita interferir con el código ejecutado normalmente." (tomado del Thinking in C++, traducción de David Villa et. al.). Esta genial y breve definición remarca lo poco intrusivo que es en principio este mecanismo, algo muy valorable, y posible gracias a que cuenta con la complicidad del propio lenguaje, y hasta del sistema operativo. Pero lo más interesante son las garantías adicionales que nos brinda, si lo combinamos con la filosofía RAII que pregona desde hace años Stroustrup (y que cada vez se manifiesta más en las bibliotecas estándar).

La idea aplica a todo recurso que deba ser de alguna forma inicializado antes de usar y liberado luego, como al abrir y cerrar un archivo o una vía de comunicación, o como reservar y liberar memoria dinámicamente por ejemplo. Se basa en asociar esta inicialización y la posterior liberación al ciclo de vida de un objeto, mediante constructores y destructores. Porque en C++ los objetos estáticos (los que no creamos con new), tienen una vida bien acotada y predecible, de la cual se responsabiliza el compilador. Entonces tenemos garantías. Y a los objetos dinámicos, los atamos a un estático que los controle y listo.

Supongamos por ejemplo que una función, que retorna un código de error cuando falla, reserva memoria con un new al principio de la misma, y la libera al final. Si algo falla en medio y la ejecución no llega al final, puede que esa memoria no se libere (memory leak). O puede que para evitar esto tengamos que poner varios delete, uno en cada posible punto de salida. Entonces, contaminamos la función cada vez con más código asociado al control de errores. Y ni hablar si en lugar de salir por un return se sale por otro mecanismo, como una excepción o una señal. Todo un problema. En cambio, si encapsulamos ese new en el constructor de un objeto estático, y ponemos el delete en su destructor (como std::unique_ptr), podremos poner cualquier return en cualquier lugar sin preocuparnos por esa memoria, ya que al finalizar la función (por el camino que sea) la variable estática se destruye automáticamente.

Y esto de los destructores aplica también para las excepciones. Si a mitad de una función se lanza una excepción, todos los objetos construidos hasta ese momento serán destruidos correctamente (ejecutando sus correspondientes destructores en orden inverso al de su construcción), mientras que los objetos que no habían alcanzado a construirse no. Entonces, si aplicamos esta filosofía, estamos protegidos, sea cual sea el mecanismo de control de errores. Pero el detalle de que se ejecutan destructores para todas las cosas que alcanzaron a construirse, y solo para las cosas que alcanzaron a construirse, se torna útil en escenarios más rebuscados. Supongamos que una clase tiene 3 atributos. Al construir la clase, estamos construyendo estos tres atributos, en algún orden. ¿Qué pasa si el constructor del segundo lanza una excepción? Lo que pasa es que se ejecuta normalmente el destructor del primero para liberar los recursos que se habían llegado a adquirir en esa construcción parcial, y luego se delega la excepción a quien intentó construir el objeto compuesto (para el que no se ejecutará el destructor, pues no terminó de construirse).

El único "pero" que se puede poner, es que el manejo de una excepción es una de esas muy muy pocas cosas en C++ para las cuales no se puede predecir exactamente cuanto tiempo vamos a necesitar. Es decir, si falla, puede que no podamos controlar cuanto tiempo lleva procesar el fallo (cuestiones de más bajo nivel en la pila de llamadas). Entonces, en aplicaciones súper exigentes, como sistemas críticos de tiempo real, se suele recomendar no utilizarlas. Pero el 99% de los que pasamos por este blog no tenemos ese problema particular.

Entonces, con excepciones tenemos garantías de generar un error (que será un objeto, con toda la información que se nos ocurra incluirle), pero dejando el ambiente como antes de empezar, deshaciendo lo que hay que deshacer, y nada más que eso, en el orden adecuado, valiéndonos de los destructores (por eso no necesitamos un "finally" para el try-catch, como tiene Java). Encima, todo viene sin costo adicional para el caso en que no se producen errores, y sin generar obligaciones para quien no quiera considerarlos. Más no se puede pedir.

No hay comentarios:

Publicar un comentario