43 Manejo de excepciones

Las excepciones son en realidad errores durante la ejecución. Si uno de esos errores se produce y no implementamos el manejo de excepciones, el programa sencillamente terminará abruptamente. Es muy probable que si hay ficheros abiertos no se guarde el contenido de los buffers, ni se cierren, además ciertos objetos no serán destruidos, y se producirán fugas de memoria.

En programas pequeños podemos prever las situaciones en que se pueden producir excepciones y evitarlos. Las excepciones más habituales son las de peticiones de memoria fallidas.

Veamos este ejemplo, en el que intentamos crear un array de cien millones de enteros:

#include <iostream>
using namespace std;

int main() {
   int *x = NULL;
   int y = 100000000;

   x = new int[y];
   x[10] = 0;
   cout << "Puntero: " << (void *) x << endl;
   delete[] x;

   return 0;
}

El sistema operativo se quejará, y el programa terminará en el momento que intentamos asignar un valor a un elemento del array.

Podemos intentar evitar este error, comprobando el valor del puntero después del new:

#include <iostream>
using namespace std;

int main() {
   int *x = 0;
   int y = 100000000;

   x = new int[y];
   if(x) {
      x[10] = 0;
      cout << "Puntero: " << (void *) x << endl;
      delete[] x;
   } else {
      cout << "Memoria insuficiente." << endl;
   }
   return 0;
}

Pero esto tampoco funcionará, ya que es al procesar la sentencia que contiene el operador new cuando se produce la excepción. Sólo nos queda evitar peticiones de memoria que puedan fallar, pero eso no es previsible.

Sin embargo, C++ proporciona un mecanismo más potente para detectar errores de ejecución: las excepciones. Para ello disponemos de tres palabras reservadas extra: try, catch y throw, veamos un ejemplo:

#include <iostream>

using namespace std;

int main() {
   int *x;
   int y = 100000000;

   try {
      x = new int[y];
      x[0] = 10;
      cout << "Puntero: " << (void *) x << endl;
      delete[] x;
   }
   catch(std::bad_alloc&) {
      cout << "Memoria insuficiente" << endl;
   }

   return 0;
}

La manipulación de excepciones consiste en transferir la ejecución del programa desde el punto donde se produce la excepción a un manipulador que coincida con el motivo de la excepción.

Como vemos en este ejemplo, un manipulador consiste en un bloque try, donde se incluye el código que puede producir la excepción.

A continuación encontraremos uno o varios manipuladores asociados al bloque try, cada uno de esos manipuladores empiezan con la palabra catch, y entre paréntesis una referencia o un objeto.

En nuestro ejemplo se trata de una referencia a un objeto bad_alloc, que es el asociado a excepciones consecuencia de aplicar el operador new.

También debe existir una expresión throw, dentro del bloque try. En nuestro caso es implícita, ya que se trata de una excepción estándar, pero podría haber un throw explícito, por ejemplo:

#include <iostream>

using namespace std;

int main() {
   try {
      throw 'x'; // valor de tipo char
   }
   catch(char c) {
      cout << "El valor de c es: " << c << endl;
   }
   catch(int n) {
      cout << "El valor de n es: " << n << endl;
   }

   return 0;
}

El throw se comporta como un return. Lo que sucede es lo siguiente: el valor devuelto por el throw se asigna al objeto del catch adecuado. En este ejemplo, al tratarse de un carácter, se asigna a la variable 'c', en el catch que contiene un parámetro de tipo char.

En el caso del operador new, si se produce una excepción, se hace un throw de un objeto de la clase std::bad_alloc, y como no estamos interesados en ese objeto, sólo usamos el tipo, sin nombre.

El manipulador puede ser invocado por un throw que se encuentre dentro del bloque try asociado, o en una de las funciones llamadas desde él.

Cuando se produce una excepción se busca un manipulador apropiado en el rango del try actual. Si no se encuentra se retrocede al anterior, de modo recursivo, hasta encontrarlo. Cuando se encuentra se destruyen todos los objetos locales en el nivel donde se ha localizado el manipulador, y en todos los niveles por los que hemos pasado.

#include <iostream>

using namespace std;

int main() {
   try {
      try {
         try {
            throw 'x'; // valor de tipo char
         }
         catch(int i) {}
         catch(float k) {}
      }
      catch(unsigned int x) {}
   }
   catch(char c) {
      cout << "El valor de c es: " << c << endl;
   }

   return 0;
}

En este ejemplo podemos comprobar que a pesar de haber hecho el throw en el tercer nivel del try, el catch que lo procesa es el del primer nivel.

Los tipos de la expresión del throw y el especificado en el catch deben coincidir, o bien, el tipo del catch debe ser una clase base de la expresión del throw. La concordancia de tipos es muy estricta, por ejemplo, no se considera como el mismo tipo int que unsigned int.

Si no se encontrase ningún catch adecuado, se abandona el programa, del mismo modo que si se produce una excepción y no hemos hecho ningún tipo de manipulación de excepciones. Los objetos locales no se destruyen, etc.

Para evitar eso existe un catch general, que captura cualquier throw para el que no exista un catch concreto:

#include <iostream>

using namespace std;

int main() {
   try {
      throw 'x'; //
   }
   catch(int c) {
      cout << "El valor de c es: " << c << endl;
   }
   catch(...) {
      cout << "Excepción imprevista" << endl;
   }

   return 0;
}

La clase "exception"

Existe una clase base exception de la que podemos heredar nuestras propias clases derivadas para pasar objetos a los manipuladores. Esto nos ahorra cierto trabajo, ya que aplicando polimorfismo necesitamos un único catch para procesar todas las posibles excepciones.

Esta clase base de declara en el fichero de cabecera estándar "exception", y tiene la siguiente forma (muy sencilla):

  class exception {
  public:
    exception() throw() { }
    virtual ~exception() throw();
    virtual const char* what() const throw();
  };

Sólo contiene tres funciones: el constructor, el destructor y la función what, estas dos últimas virtuales. La función what debe devolver una cadena que indique el motivo de la excepción.

Verás que después del nombre de la función, en el caso del destructor y de la función what aparece un añadido throw(), esto sirve para indicar que estas funciones no pueden producir ningún tipo de excepción, es decir, que no contienen sentencias throw.

Podemos hacer una prueba sobre este tema, por ejemplo, crearemos un programa que copie un fichero.

#include <iostream>
#include <fstream>
using namespace std;

void CopiaFichero(const char* Origen, const char *Destino);

int main() {
   char Desde[] = "excepcion.cpt"; // Este fichero
   char Hacia[] = "excepcion.cpy";

   CopiaFichero(Desde, Hacia);
   cin.get();
   return 0;
}

void CopiaFichero(const char* Origen, const char *Destino) {
   unsigned char buffer[1024];
   int leido;
   ifstream fe(Origen, ios::in | ios::binary);
   ofstream fs(Destino, ios::out | ios::binary);

   do {
      fe.read(reinterpret_cast<char *> (buffer), 1024);
      leido = fe.gcount();
      fs.write(reinterpret_cast<char *> (buffer), leido);
   } while(leido);
   fe.close();
   fs.close();
}

Ahora lo modificaremos para que se genere una excepción si el fichero origen no existe o el de destino no puede ser abierto:

#include <iostream>
#include <fstream>
using namespace std;

// Clase derivada de "exception" para manejar excepciones
// de copia de ficheros.
class CopiaEx: public exception {
  public:
   CopiaEx(int mot) : exception(), motivo(mot) {}
   const char* what() const throw();
  private:
   int motivo;
};

const char* CopiaEx::what() const throw() {
   switch(motivo) {
     case 1:
        return "Fichero de origen no existe";
     case 2:
        return "No es posible abrir el fichero de salida";
   }
   return "Error inesperado";
} // (1)

void CopiaFichero(const char* Origen, const char *Destino);

int main() {
   char Desde[] = "excepcion.cpp"; // Este fichero
   char Hacia[] = "excepcion.cpy";

   try {
      CopiaFichero(Desde, Hacia);
   }
   catch(CopiaEx &ex) {
      cout << ex.what() << endl;
   } // (2)
   return 0;
}

void CopiaFichero(const char* Origen, const char *Destino) {
   unsigned char buffer[1024];
   int leido;
   ifstream fe(Origen, ios::in | ios::binary);
   if(!fe.good()) throw CopiaEx(1); // (3)

   ofstream fs(Destino, ios::out | ios::binary);
   if(!fs.good()) throw CopiaEx(2); // (4)

   do {
      fe.read(reinterpret_cast<char *> (buffer), 1024);
      leido = fe.gcount();
      fs.write(reinterpret_cast<char *> (buffer), leido);
   } while(leido);
   fe.close();
   fs.close();
}

Ejecutar este código en OnlineGDB.

Espero que esté claro lo que hemos hecho. En (1) hemos derivado una clase de exception, para hacer el tratamiento de nuestras propias excepciones. Hemos redefinido la función virtual what para que muestre los mensajes de error que hemos predefinido para los dos posibles errores que queremos detectar.

En (2) hemos hecho el tratamiento de excepciones, propiamente dicho. Intentamos copiar el fichero, y si no es posible, mostramos el mensaje de error.

Dentro de la función "CopiaFichero" intentamos abrir el fichero de entrada, si fracasamos hacemos un throw con el valor 1, lo mismo con el de salida, pero con un valor 2 para throw.

Ahora podemos intentar ver qué pasa si el fichero de entrada no existe, o si el fichero de salida existe y está protegido contra escritura.

Hay que observar que el objeto que obtenemos en el catch es una referencia. Esto es recomendable, ya que evitamos copiar el objeto devuelto por el throw. Imaginemos que se trata de un objeto más grande, y que la excepción que maneja está relacionada con la memoria disponible. Si pasamos el objeto por valor estaremos obligando al programa a usar más memoria, y puede que no exista suficiente.

Orden en la captura de excepciones

Cuando se derivan clases desde la clase base "exception" hay que tener cuidado en el orden en que las capturamos. Debido que se aplica polimorfismo, cualquier objeto de la jerarquía se ajustará al catch que tenga por argumento un objeto o referencia de la clase base, y sucesivamente, con cada una de las clases derivadas.

Por ejemplo, podemos crear una clase derivada de "exception", "Excep2", otra derivada de ésta, "Excep3", para hacer un tratamiento de excepciones de uno de nuestros programas:

class Excep2 : public exception {}
class Excep3 :public Excep2 {}
...
   try {
      // Nuestro código
   }
   catch(Excep2&) {
      // tratamiento
   }
   catch(Excep1&) {
      // tratamiento
   }
   catch(exception&) {
      // tratamiento
   }
   catch(...) {
      // tratamiento
   }
...

Si usamos otro orden, perderemos la captura de determinados objetos, por ejemplo, supongamos que primero hacemos el catch de "exception":

class Excep2 : public exception {}
class Excep3 :public Excep2 {}
...
   try {
      // Nuestro código
   }
   catch(exception&) {
      // tratamiento
   }
   catch(Excep2&) { // No se captura
      // tratamiento
   }
   catch(Excep1&) { // No se captura
      // tratamiento
   }
   catch(...) {
      // tratamiento
   }
...

En este caso jamás se capturará una excepción mediante "Excep2" y "Excep3".

Especificaciones de excepciones

Se puede añadir una especificación de las posibles excepciones que puede producir una función:

<tipo> <identificador>(<parametros>) throw(<lista_excepciones>);

De este modo indicamos que la función sólo puede hacer un throw de uno de los tipos especificados en la lista, si la lista está vacía indica que la función no puede producir excepciones.

El compilador no verifica si realmente es así, es decir, podemos hacer un throw con un objeto de uno de los tipos listados, y el compilador no notificará ningún error. Sólo se verifica durante la ejecución, de modo que si se produce una excepción no permitida, el programa sencillamente termina.

Veamos algunos ejemplos:

int Compara(int, int) throw();

Indica que la función "Compara" no puede producir excepciones.

int CrearArray(int) throw(std::bad_alloc);

Indica que la función "CrearArray" sólo puede producir excepciones por memoria insuficiente.

int MiFuncion(char *) throw(std::bad_alloc, ExDivCero);

Indica que la función "MiFuncion" puede producir excepciones por falta de memoria o por división por cero.

Excepciones en constructores y destructores

Uno de los lugares donde más frecuentemente se requiere un tratamiento de excepciones es en los constructores de las clases, normalmente, esos constructores hacen peticiones de memoria, verifican condiciones, leen valores iniciales desde ficheros, etc.

Aunque no hay ningún problema en eso, no es así con los destructores, está desaconsejado que los destructores puedan producir excepciones. La razón es sencilla, los destructores pueden ser invocados automáticamente cuando se procesa una excepción, y si durante ese proceso se produce de nuevo una excepción, el programa terminará inmediatamente.

Sin embargo, si necesitamos generar una excepción desde un destructor, existe un mecanismo que nos permite comprobar si se está procesando una excepción, y en ese caso, no ejecutamos la sentencia throw. Se trata de la función estándar: uncaught_exception, que devuelve el valor true si se está procesando una excepción":

class CopiaEx {};

Miclase::~MiClase() throw (CopiaEx) {
   // Necesitamos copiar un fichero cuando se
   // destruya un objeto de esta clase, pero
   // CopiaFichero puede generar una excepción
   // De modo que antes averiguamos si ya se
   // está procesando una:
   if(uncaught_exception()) return; // No hacemos nada

   // En caso contrario, intentamos hacer la copia:
   CopiaFichero("actual.log", "viejo.log");
}

Pero, es mejor idea hacer el tratamiento de excepciones dentro del propio destructor:

class CopiaEx {};

Miclase::~MiClase() throw () {
   try {
      CopiaFichero("actual.log", "viejo.log");
   }
   catch(CopiaEx&) {
      cout << "No se pudo copiar el fichero 'actual.log'"
           << endl;
   }
}

Excepciones estándar

Existen cuatro excepciones estándar, derivadas de la clase "exception", y asociadas a un operador o a un error de especificación:

std::bad_alloc      // Al operador new
std::bad_cast       // Al operador dynamic_cast<>
std::bad_typeid     // Al operador typeid
std::bad_exception  // Cuando se viola una especificación

Cada vez que se usa uno de los operadores mencionados, puede producirse una excepción. Un programa bien hecho debe tener esto en cuenta, y hacer el tratamiento de excepciones cuando se usen esos operadores. Esto creará aplicaciones robustas y seguras.

Relanzar una excepción

Ya sabemos que los bloques try pueden estar anidados, y que si se produce una excepción en un nivel interior, y no se captura en ese nivel, se lanzará la excepción al siguiente nivel en el orden de anidamiento.

Pero también podemos lanzar una excepción a través de los siguientes niveles, aunque la hayamos capturado. A eso se le llama relanzarla, y para ello se usa throw;, sin argumentos:

#include <iostream>
using namespace std;

void Programa();

int main() {
   try {
      // Programa
      Programa();
   }
   catch(int x) {
      cout << "Excepción relanzada capturada." << endl;
      cout << "error: " << x << endl;
   }
   catch(...) {
      cout << "Excepción inesperada." << endl;
   }

   cin.get();
   return 0;
}

void Programa() {
   try {
      // Operaciones...
      throw 10;
   }
   catch(int x) {
      // Relanzar, no nos interesa manejar aquí
      throw;
   }
}

La función no hace nada con la excepción capturada, excepto reenviarla a nivel siguiente, donde podremos capturarla de nuevo.